Devemos definir tipos para tudo?

138

Recentemente, tive um problema com a legibilidade do meu código.
Eu tinha uma função que fazia uma operação e retornava uma string representando o ID dessa operação para referência futura (um pouco como OpenFile no Windows retornando um handle). O usuário usaria esse ID mais tarde para iniciar a operação e monitorar sua conclusão.

O ID tinha que ser uma string aleatória devido a preocupações de interoperabilidade. Isso criou um método que tinha uma assinatura muito clara como esta:

public string CreateNewThing()

Isso torna a intenção do tipo de retorno não clara. Eu pensei em envolver essa string em outro tipo que torna seu significado mais claro assim:

public OperationIdentifier CreateNewThing()

O tipo conterá apenas a string e será usado sempre que esta string for usada.

É óbvio que a vantagem desse modo de operação é mais segurança de tipo e intenção mais clara, mas também cria muito mais código e código que não é muito idiomático. Por um lado, eu gosto da segurança adicional, mas também cria muita confusão.

Você acha que é uma boa prática envolver tipos simples em uma aula por motivos de segurança?

    
por Ziv 03.05.2015 / 18:30
fonte

10 respostas

150

As primitivas, como string ou int , não têm significado em um domínio de negócios. Uma conseqüência direta disso é que você pode usar um URL por engano quando um ID de produto é esperado ou usar quantidade ao esperar preço .

É também por isso que o desafio Object Calisthenics apresenta as primitivas embrulho como uma de suas regras:

Rule 3: Wrap all primitives and Strings

In the Java language, int is a primitive, not a real object, so it obeys different rules than objects. It is used with a syntax that isn’t object-oriented. More importantly, an int on its own is just a scalar, so it has no meaning. When a method takes an int as a parameter, the method name needs to do all of the work of expressing the intent. If the same method takes an Hour as a parameter, it’s much easier to see what’s going on.

O mesmo documento explica que há um benefício adicional:

Small objects like Hour or Money also give us an obvious place to put behavior that would otherwise have been littered around other classes.

De fato, quando os primitivos são usados, normalmente é extremamente difícil rastrear a localização exata do código relacionado a esses tipos, geralmente levando a uma severa duplicação de código . Se houver Price: Money class, é natural encontrar a verificação dentro do intervalo. Se, em vez disso, um int (pior, um double ) é usado para armazenar os preços dos produtos, quem deve validar o intervalo? O produto? O desconto? O carrinho?

Finalmente, um terceiro benefício não mencionado no documento é a capacidade de alterar relativamente fácil o tipo subjacente. Se hoje meu ProductId tiver short como seu tipo subjacente e depois eu precisar usar int , provavelmente o código a ser alterado não abrangerá toda a base de código.

A desvantagem - e o mesmo argumento aplica-se a todas as regras do exercício da Object Calisthenics - é que se torna rapidamente muito avassalador para criar uma classe para tudo . Se Product contiver ProductPrice que herda de PositivePrice que herda de Price , que por sua vez herda de Money , essa não é uma arquitetura limpa, mas sim uma bagunça completa onde, para encontrar uma única coisa, uma mantenedor deve abrir algumas dezenas de arquivos o tempo todo.

Outro ponto a considerar é o custo (em termos de linhas de código) de criar classes adicionais. Se os invólucros são imutáveis (como deveriam ser, geralmente), isso significa que, se tomarmos C #, você tem que ter, dentro do wrapper, pelo menos:

  • O getter de propriedade,
  • Seu campo de apoio,
  • Um construtor que atribui o valor ao campo de apoio,
  • Um ToString() personalizado,
  • comentários da documentação XML (o que torna muito de linhas),
  • Um Equals e um GetHashCode substitui (também um monte de LOC).

e, eventualmente, quando relevante:

  • Um atributo DebuggerDisplay ,
  • Uma substituição de == e != operadores,
  • Eventualmente, uma sobrecarga do operador de conversão implícito para converter e para o tipo encapsulado,
  • Contratos de código (incluindo o invariante, que é um método bastante longo, com seus três atributos),
  • Diversos conversores que serão utilizados durante a serialização de XML, serialização de JSON ou armazenamento / carregamento de um valor para / de um banco de dados.

Uma centena de LOC para um simples invólucro faz com que seja bastante proibitivo, e é por isso que você pode estar completamente certo da rentabilidade a longo prazo de tal invólucro. A noção de escopo explicada por Thomas Junk é particularmente relevante aqui. Escrever cem LOCs para representar um ProductId usado em toda a sua base de código parece bastante útil. Escrever uma classe desse tamanho para um pedaço de código que faz três linhas em um único método é muito mais questionável.

Conclusão:

  • Faça quebra de primitivas em classes que tenham um significado em um domínio de negócios do aplicativo quando (1) ajudar a reduzir erros, (2) reduzir o risco de duplicação de código ou (3) ajudar a alterar o tipo subjacente posteriormente .

  • Não agrupe automaticamente todas as primitivas encontradas em seu código: há muitos casos em que usar string ou int é perfeitamente aceitável.

Na prática, em public string CreateNewThing() , retornar uma instância de ThingId class em vez de string pode ajudar, mas você também pode:

  • Retorna uma instância de Id<string> class, que é um objeto do tipo genérico, indicando que o tipo subjacente é uma string. Você tem o seu benefício de legibilidade, sem a desvantagem de ter que manter muitos tipos.

  • Retorna uma instância da classe Thing . Se o usuário precisar apenas do ID, isso pode ser feito facilmente com:

    var thing = this.CreateNewThing();
    var id = thing.Id;
    
por 03.05.2015 / 19:09
fonte
59

Eu usaria o escopo como regra geral: quanto menor o escopo de gerar e consumir tal values , menor a probabilidade de criar um objeto que represente esse valor.

Digamos que você tenha o seguinte pseudocódigo

id = generateProcessId();
doFancyOtherstuff();
job.do(id);

então o escopo é muito limitado e eu não vejo sentido em torná-lo um tipo. Mas digamos, você gera esse valor em uma camada e passa para outra camada (ou até mesmo para outro objeto), então faria todo o sentido criar um tipo para isso.

    
por 03.05.2015 / 19:15
fonte
34

Os sistemas de tipo estático têm tudo a ver com a prevenção de usos incorretos de dados.

Existem exemplos óbvios de tipos que fazem isso:

  • você não pode obter o mês de um UUID
  • você não pode multiplicar duas strings.

Existem exemplos mais sutis

  • você não pode pagar por algo usando o comprimento da mesa
  • você não pode fazer uma solicitação HTTP usando o nome de alguém como o URL.

Podemos ficar tentados a usar double para preço e duração ou usar string para um nome e um URL. Mas fazer isso subverte nosso incrível sistema de tipos e permite que esses erros passem pelas verificações estáticas da linguagem.

Pressionar segundos-segundos com Newton-seconds pode ter resultados ruins em tempo de execução .

Isso é particularmente um problema com strings. Eles freqüentemente se tornam o "tipo de dados universal".

Estamos acostumados principalmente a interfaces de texto com computadores, e geralmente estendemos essas interfaces humanas (UIs) para interfaces de programação (APIs). Pensamos em 34.25 como os caracteres 34.25 . Pensamos em uma data como os personagens 05-03-2015 . Pensamos em um UUID como os caracteres 75e945ee-f1e9-11e4-b9b2-1697f925ec7b .

Mas esse modelo mental prejudica a abstração da API.

The words or the language, as they are written or spoken, do not seem to play any role in my mechanism of thought.

Albert Einstein

Da mesma forma, as representações textuais não devem desempenhar nenhum papel na criação de tipos e APIs. Cuidado com o string ! (e outros tipos "primitivos" excessivamente genéricos)

Tipos comunicam "que operações fazem sentido".

Por exemplo, uma vez trabalhei em um cliente para uma API REST HTTP. REST, feito corretamente, usa entidades hipermídia, que possuem hyperlinks apontando para entidades relacionadas. Nesse cliente, não apenas as entidades foram digitadas (por exemplo, Usuário, Conta, Assinatura), os links para essas entidades também foram digitados (UserLink, AccountLink, SubscriptionLink). Os links eram pouco mais que invólucros em torno de Uri , mas os tipos separados tornavam impossível tentar usar um AccountLink para buscar um usuário. Se tudo estivesse claro Uri s - ou pior, string - esses erros só seriam encontrados em tempo de execução.

Da mesma forma, na sua situação, você tem dados que são usados apenas para uma finalidade: identificar um Operation . Ele não deve ser usado para mais nada, e não devemos tentar identificar Operation s com strings aleatórias que criamos. Criar uma classe separada adiciona legibilidade e segurança ao seu código.

Naturalmente, todas as coisas boas podem ser usadas em excesso. Considere

  1. quanta clareza ele adiciona ao seu código

  2. com que frequência é usado

Se um "tipo" de dados (no sentido abstrato) for usado freqüentemente, para propósitos distintos, e entre interfaces de código, é um candidato muito bom para ser uma classe separada, às custas de verbosidade.

    
por 04.05.2015 / 01:31
fonte
10

Eu geralmente concordo que muitas vezes você deve criar um tipo de primitivas e strings, mas como as respostas acima recomendam a criação de um tipo na maioria dos casos, listarei algumas razões por que / quando não:

  • Desempenho. Eu devo me referir às línguas atuais aqui. Em C #, se um ID de cliente é curto e você o envolve em uma classe - você acabou de criar muita sobrecarga: memória, já que agora é 8 bytes em um sistema de 64 bits e velocidade desde que agora está alocado na pilha.
  • Quando você faz suposições sobre o tipo. Se um ID de cliente é curto e você tem alguma lógica que o empacota de alguma maneira - você normalmente já faz suposições sobre o tipo. Em todos esses lugares você tem que quebrar a abstração. Se é um ponto, não é grande coisa, se estiver em todo lugar, você pode descobrir que na metade do tempo você está usando o primitivo.
  • Porque nem toda linguagem tem typedef. E para idiomas que não o fazem e código que já está escrito, fazer essa mudança pode ser uma grande tarefa que pode até introduzir bugs (mas todo mundo tem uma suíte de testes com boa cobertura, certo?).
  • Em alguns casos, reduz a legibilidade . Como imprimo isso? Como eu valido isso? Devo estar verificando nulo? São todas as perguntas que exigem que você faça drill na definição do tipo.
por 04.05.2015 / 16:41
fonte
9

Do you think it is a good practice to wrap simple types in a class for safety reasons?

Às vezes.

Este é um daqueles casos em que você precisa ponderar os problemas que podem surgir ao usar um string em vez de um OperationIdentifier mais concreto. Quais são sua gravidade? Qual é a sua probabilidade?

Depois, você precisa considerar o custo de usar o outro tipo. Quão doloroso é usar? Quanto trabalho é para fazer?

Em alguns casos, você economizará tempo e esforço tendo um bom tipo de concreto para trabalhar. Em outros, não valerá a pena.

Em geral, acho que esse tipo de coisa deveria ser feito mais do que é hoje. Se você tem uma entidade que significa algo em seu domínio, é bom ter isso como seu próprio tipo, pois é mais provável que essa entidade mude / cresça com o negócio.

    
por 03.05.2015 / 18:41
fonte
7

Não, você não deve definir tipos (classes) para "tudo".

Mas, como outras respostas afirmam, muitas vezes é útil fazê-lo. Você deve desenvolver - conscientemente, se possível - um sentimento ou uma sensação de muito atrito devido à ausência de um tipo ou classe adequada, conforme você escreve, testa e mantém seu código. Para mim, o início de muito atrito é quando quero consolidar vários valores primitivos como um valor único ou quando preciso validar os valores (isto é, determinar quais de todos os valores possíveis do tipo primitivo correspondem a valores válidos do tipo primitivo). 'tipo implícito').

Descobri que considerações como o que você fez em sua pergunta são responsáveis pelo design demais do meu código. Eu desenvolvi o hábito de evitar deliberadamente escrever mais código do que o necessário. Espero que você esteja escrevendo bons testes (automatizados) para o seu código - se você estiver, então poderá refatorar seu código facilmente e adicionar tipos ou classes se isso fornecer benefícios líquidos para o desenvolvimento e manutenção contínuos de seu código .

Resposta da Telastyn e A resposta de Thomas Junk faz um ponto muito bom sobre o contexto e o uso do código relevante. Se você estiver usando um valor dentro de um único bloco de código (por exemplo, método, loop, using block), então é bom usar apenas um tipo primitivo. É bom usar um tipo primitivo se você estiver usando um conjunto de valores repetidamente e em muitos outros lugares. Mas quanto mais frequente e amplamente você estiver usando um conjunto de valores, e quanto menos esse conjunto de valores corresponder aos valores representados pelo tipo primitivo, mais você deve considerar encapsular os valores em uma classe ou tipo.

    
por 03.05.2015 / 23:09
fonte
5

Na superfície, parece que tudo que você precisa fazer é identificar uma operação.

Além disso, você diz o que uma operação deve fazer:

to start the operation [and] to monitor its finish

Você diz isso de uma maneira como se isso fosse "apenas como a identificação é usada", mas eu diria que essas são propriedades descrevendo uma operação. Isso soa como uma definição de tipo para mim, existe até um padrão chamado "padrão de comando" que se encaixa muito bem. .

Diz

the command pattern [...] is used to encapsulate all information needed to perform an action or trigger an event at a later time.

Acho que isso é muito semelhante ao que você deseja fazer com suas operações. (Compare as frases que fiz em negrito em ambas aspas) Em vez de retornar uma string, retorne um identificador para um Operation no sentido abstrato, um ponteiro para um objeto dessa classe em oop por exemplo.

Em relação ao comentário

It would be wtf-y to call this class a command and then not have the logic inside it.

Não, não. Lembre-se de que os padrões são muito abstratos , tão abstratos no fato de serem um pouco meta. Isto é, eles geralmente abstraem a programação em si e não algum conceito do mundo real. O padrão de comando é uma abstração de uma chamada de função. (ou método) Como se alguém apertasse o botão de pausa logo após passar os valores dos parâmetros e logo antes da execução, para ser retomado mais tarde.

O que se segue é considerar oop, mas a motivação por trás disso deve valer para qualquer paradigma. Eu gostaria de dar algumas razões pelas quais colocar a lógica no comando pode ser considerado uma coisa ruim.

  1. Se você tivesse toda essa lógica no comando, seria inchado e confuso. Você estaria pensando "oh bem, toda essa funcionalidade deve estar em classes separadas. "Você iria refatorar a lógica fora do comando.
  2. Se você tivesse toda essa lógica no comando, seria difícil testar e atrapalhe ao testar. "Caramba, por que eu tenho que usar esse absurdo de comando? Eu só quero testar se esta função cospe sai 1 ou não, eu não quero ligar depois, eu quero testá-lo agora! "
  3. Se você tivesse toda essa lógica no comando, não faria muito sentido relatar quando terminar. Se você pensar em uma função para ser executado de forma síncrona por padrão, não faz sentido ser notificado quando terminar a execução. Talvez você esteja começando outra thread porque sua operação é realmente para tornar avatar para cinema formato (pode precisar de thread adicional em framboesa pi), talvez você tenha para buscar alguns dados de um servidor ... o que quer que seja, se houver motivo para relatar quando terminar, pode ser porque há alguma assincronia (é que uma palavra?) está acontecendo. Acho que executar um thread ou entrar em contato com um servidor é a lógica que não deve estar no seu comando. Isso é um pouco um meta argumento e talvez controverso. Se você acha que não faz sentido, por favor me diga nos comentários.

Para recapitular: o padrão de comando permite que você envolva a funcionalidade  em um objeto, para ser executado mais tarde. Por uma questão de modularidade (funcionalidade existe independentemente de ser executado via comando ou não), testabilidade (funcionalidade deve ser testável sem comando) e todas as outras palavras que expressam essencialmente a demanda para escrever um bom código, você não colocaria a lógica real no comando.

Como os padrões são abstratos, pode ser difícil encontrar boas metáforas do mundo real. Aqui está uma tentativa:

"Ei avó, você poderia por favor apertar o botão de gravação na TV às 12, para não perder os simpsons no canal 1?"

Minha avó não sabe o que acontece tecnicamente quando ela aperta o botão de gravação. A lógica está em outro lugar (na TV). E isso é uma coisa boa. A funcionalidade é encapsulada e a informação é escondida do comando, é um usuário da API, não necessariamente parte da lógica ... oh jeez eu estou balbuciando palavras de zumbido novamente, é melhor eu terminar essa edição agora.

    
por 04.05.2015 / 18:37
fonte
5

Idéia por trás do envolvimento de tipos primitivos,

  • Para estabelecer um idioma específico do domínio
  • Limite os erros cometidos pelos usuários passando valores incorretos

Obviamente, seria muito difícil e não prático fazer isso em todos os lugares, mas é importante envolver os tipos onde for necessário,

Por exemplo, se você tiver a ordem da classe,

Order
{
   long OrderId { get; set; }
   string InvoiceNumber { get; set; }
   string Description { get; set; }
   double Amount { get; set; }
   string CurrencyCode { get; set; }
}

As propriedades importantes para pesquisar um pedido são principalmente OrderId e InvoiceNumber. E o valor e o código da moeda estão intimamente relacionados, onde, se alguém alterar o código da moeda sem alterar a quantidade, o pedido não poderá mais ser considerado válido.

Assim, apenas envolver OrderId, InvoiceNumber e introduzir um composto para moeda faz sentido neste cenário e, provavelmente, a descrição de invólucro não faz sentido. Então o resultado preferido poderia parecer,

    Order
    {
       OrderIdType OrderId { get; set; }
       InvoiceNumberType InvoiceNumber { get; set; }
       string Description { get; set; }
       Currency Amount { get; set; }
    }

Portanto, não há motivo para envolver tudo, mas as coisas que realmente importam.

    
por 04.05.2015 / 10:02
fonte
4

Opinião impopular:

Normalmente você não deve definir um novo tipo!

Definir um novo tipo para agrupar uma classe primitiva ou básica é algumas vezes chamado de definir um pseudo-tipo. A IBM descreve isso como uma prática ruim aqui (eles se concentram especificamente no uso indevido com genéricos neste caso).

Os tipos de pseudo tornam a funcionalidade da biblioteca comum inútil.

As funções matemáticas do Java podem trabalhar com todas as primitivas numéricas. Mas se você definir uma nova classe Porcentagem (envolvendo um dobro que pode estar no intervalo 0 ~ 1) todas essas funções são inúteis e precisam ser agrupadas por classes que (pior ainda) precisam saber sobre a representação interna da classe Porcentagem .

Tipos de pseudo são virais

Ao criar várias bibliotecas, você frequentemente descobrirá que esses pseudotipos se tornam virais. Se você usar a classe Porcentagem acima mencionada em uma biblioteca, será necessário convertê-la no limite da biblioteca (perdendo toda a segurança / significado / lógica / outra razão que tenha tido para criar esse tipo) ou terá que fazer essas classes acessível para a outra biblioteca também. Infectando a nova biblioteca com seus tipos, onde um simples duplo poderia ter bastado.

Retire a mensagem

Contanto que o tipo que você envolve não precise de muita lógica de negócios, eu aconselharia encapsulá-lo em uma pseudo-classe. Você só deve envolver uma turma se houver restrições comerciais sérias. Em outros casos, nomear corretamente suas variáveis deve percorrer um longo caminho para transmitir significado.

Um exemplo:

Um uint pode representar perfeitamente um UserId, podemos continuar usando os operadores internos do Java para uint s (como ==) e não precisamos de lógica comercial para proteger o 'estado interno' do ID do usuário .

    
por 06.05.2015 / 10:29
fonte
3

Como em todas as dicas, saber quando aplicar as regras é a habilidade. Se você criar seus próprios tipos em um idioma orientado por tipos, obterá a verificação de tipos. Então, geralmente, esse será um bom plano.

NamingConvention é essencial para a legibilidade. Os dois combinados podem transmitir claramente a intenção.
Mas /// ainda é útil.

Então, sim, eu diria criar muitos tipos quando a vida deles estiver além do limite da classe. Considere também o uso de ambos os Struct e Class , nem sempre da classe.

    
por 03.05.2015 / 18:44
fonte