Oi Habr!
Um dia desses, mais uma vez, recebi o código de tipo
if(someParameter.Volatilities.IsEmpty()) { // We have to report about the broken channels, however we could not differ it from just not started cold system. // Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line Log.Info("Channel {0} is broken or was not started yet", someParameter.Key) }
Há um recurso bastante importante no código: o destinatário gostaria muito de saber o que realmente aconteceu. De fato, em um caso, temos problemas com o sistema e, no outro, apenas aquecemos. No entanto, o modelo não nos fornece isso (para agradar o remetente, que geralmente é o autor do modelo).
Além disso, mesmo o fato de "talvez algo esteja errado" decorre do fato de a coleção Volatilities
vazia. O que em alguns casos pode estar correto.
Tenho certeza de que os desenvolvedores mais experientes do código viram linhas que continham conhecimento secreto no estilo de "se essa combinação de sinalizadores estiver definida, será solicitado que você faça A, B e C" (embora isso não seja visível pelo próprio modelo).
Do meu ponto de vista, essas economias na estrutura das classes têm um impacto extremamente negativo no projeto no futuro, transformando-o em um conjunto de hacks e muletas, transformando gradualmente um código mais ou menos conveniente em legado.
Importante: no artigo, dou exemplos úteis para projetos nos quais vários desenvolvedores (e não um), além de quais serão atualizados e expandidos por pelo menos 5 a 10 anos. Tudo isso não faz sentido se o projeto tiver um desenvolvedor por cinco anos ou se nenhuma mudança for planejada após o lançamento. E é lógico que, se o projeto for necessário por apenas alguns meses, não faz sentido investir em um modelo de dados claro.
No entanto, se você está jogando muito tempo - bem-vindo ao gato.
Usar padrão de visitante
Geralmente, o mesmo campo contém um objeto que pode ter significados semânticos diferentes (como no exemplo). No entanto, para salvar classes, o desenvolvedor deixa apenas um tipo, fornecendo sinalizadores (ou comentários no estilo "se não houver nada aqui, nada foi contado"). Uma abordagem semelhante pode mascarar um erro (o que é ruim para o projeto, mas conveniente para a equipe que fornece o serviço, porque os erros não são visíveis do lado de fora). Uma opção mais correta, que permite que mesmo no extremo oposto do fio descubra o que realmente está acontecendo, é usar a interface + os visitantes.
Nesse caso, o exemplo do cabeçalho se transforma em código do formulário:
class Response { public IVolatilityResponse Data { get; } } interface IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) } class VolatilityValues : IVolatilityResponse { public Surface Data; TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } class CalculationIsBroken : IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } interface IVolatilityResponseVisitor<TInput, TOutput> { TOutput Visit(VolatilityValues instance, TInput input); TOutput Visit(CalculationIsBroken instance, TInput input); }
Com este tipo de processamento:
- Precisamos de mais código. Infelizmente, se queremos expressar mais informações no modelo, deve ser mais.
- Devido a esse tipo de herança, não podemos mais serializar o
Response
para json
/ protobuf
, pois as informações do tipo são perdidas lá. Teremos que criar um contêiner especial que fará isso (por exemplo, você pode criar uma classe que contenha um campo separado para cada implementação, mas apenas um deles será preenchido). - Estender o modelo (ou seja, adicionar novas classes) requer a expansão da
IVolatilityResponseVisitor<TInput, TOutput>
, o que significa que o compilador forçará o suporte no código. O programador não esquecerá de processar o novo tipo, caso contrário, o projeto não será compilado. - Devido à digitação estática, não precisamos armazenar a documentação em algum lugar com possíveis combinações de campos etc. Descrevemos todas as opções possíveis no código que é compreensível para o compilador e a pessoa. Não teremos uma dessincronização entre documentação e código, pois podemos ficar sem o primeiro.
Sobre restrição de herança em outros idiomas
Vários outros idiomas (por exemplo, Scala
ou Kotlin
) têm palavras-chave que permitem proibir a herança de um determinado tipo, sob certas condições. Assim, na fase de compilação, conhecemos todos os possíveis descendentes do nosso tipo.
Em particular, o exemplo acima pode ser reescrito no Kotlin
assim:
class Response ( val data: IVolatilityResponse ) sealed class VolatilityResponse class VolatilityValues : VolatilityResponse() { val data: Surface } class CalculationIsBroken : VolatilityResponse()
Ficou um pouco menos do que o código, mas agora no processo de compilação sabemos que todos os VolatilityResponse
possíveis do VolatilityResponse
estão no mesmo arquivo, o que significa que o código a seguir não será compilado, pois não analisamos todos os valores possíveis da classe.
fun getResponseString(response: VolatilityResponse) = when(response) { is VolatilityValues -> data.toString() }
No entanto, vale lembrar que essas verificações funcionam apenas para chamadas funcionais. O código abaixo será compilado sem erros:
fun getResponseString(response: VolatilityResponse) { when(response) { is VolatilityValues -> println(data.toString()) } }
Nem todos os tipos primitivos significam a mesma coisa
Considere um desenvolvimento relativamente típico para um banco de dados. Provavelmente, em algum lugar do código você terá identificadores de objeto. Por exemplo:
class Group { public int Id { get; } public string Name { get; } } class User { public int Id { get; } public int GroupId { get; } public string Name { get; } }
Parece um código padrão. Os tipos até correspondem aos do banco de dados. No entanto, a pergunta é: o código abaixo está correto?
public bool IsInGroup(User user, Group group) { return user.Id == group.Id; } public User CreateUser(string name, Group group) { return new User { Id = group.Id, GroupId = group.Id, name = name } }
A resposta provavelmente não é, pois estamos comparando o Id
do usuário e o Id
grupo no primeiro exemplo. E no segundo, definimos por engano o id
do Group
como o id
do User
.
Curiosamente, isso é bastante simples de corrigir: basta obter os tipos GroupId
, UserId
e assim por diante. Assim, a criação do User
não funcionará mais, pois seus tipos não convergirão. O que é incrivelmente legal, porque você pode contar ao compilador sobre o modelo.
Além disso, métodos com os mesmos parâmetros funcionarão corretamente para você, pois agora eles não serão repetidos:
public void SetUserGroup(UserId userId, GroupId groupId) { /* some sql code */ }
No entanto, voltemos ao exemplo de comparação de identificadores. É um pouco mais complicado, pois você deve impedir que o compilador compare o incomparável durante o processo de compilação.
E você pode fazer isso da seguinte maneira:
class GroupId { public int Id { get; } public bool Equals(GroupId groupId) => Id == groupId?.Id; [Obsolete("GroupId can be equal only with GroupId", error: true)] public override bool Equals(object obj) => Equals(obj as GroupId) public static bool operator==(GroupId id1, GroupId id2) { if(ReferenceEquals(id1, id2)) return true; if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null)) return false; return id1.Id == id2.Id; } [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId") [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId") }
Como resultado:
- Novamente precisávamos de mais código. Infelizmente, se você quiser fornecer mais informações ao compilador, geralmente precisará escrever mais linhas.
- Criamos novos tipos (falaremos sobre otimizações abaixo), que às vezes podem prejudicar um pouco o desempenho.
- No nosso código:
- Proibimos confundir identificadores. Agora, o compilador e o desenvolvedor veem claramente que é impossível
GroupId
campo GroupId
para o campo GroupId
- Somos proibidos de comparar o incomparável.
IEquitable
que o código de comparação não foi completamente concluído (também é desejável implementar a interface IEquitable
, você também deve implementar o método GetHashCode
), para que o exemplo não precise apenas ser copiado para o projeto. No entanto, a ideia em si é clara: proibimos explicitamente o compilador de expressar quando os tipos errados foram comparados. I.e. em vez de dizer "essas frutas são iguais?" o compilador agora vê "uma pera é igual a uma maçã?".
Um pouco mais sobre sql e limitações
Geralmente, em nossos aplicativos para tipos, são introduzidas regras adicionais fáceis de verificar. Na pior das hipóteses, várias funções são mais ou menos assim:
void SetName(string name) { if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH) { throw .... } /**/ }
Ou seja, a função usa um tipo bastante amplo de entrada e executa as verificações. Geralmente não é esse o caso, pois:
- Não explicamos ao programador e compilador o que queremos aqui.
- Em outra função semelhante, você precisará copiar as verificações.
- Quando recebemos uma
string
que indica o name
, não caímos imediatamente, mas, por algum motivo, a execução continuada caiu em algumas instruções do processador posteriormente.
O comportamento correto:
- Crie um tipo separado (no nosso caso, aparentemente,
Name
). - Nele, faça todas as validações e verificações necessárias.
- Coloque a
string
em Name
mais rápido possível para obter um erro o mais rápido possível.
Como resultado, obtemos:
- Menos código, já que verificamos as verificações de
name
no construtor. - Estratégia Fail Fast - agora, tendo recebido um nome problemático, cairemos imediatamente, em vez de chamar mais alguns métodos, mas ainda cairemos. Além disso, em vez de um erro de um banco de dados do tipo tipo muito grande, descobrimos imediatamente que não faz sentido sequer começar a processar esses nomes.
- Já é mais difícil misturarmos os argumentos se a assinatura da função for:
void UpdateData(Name name, Email email, PhoneNumber number)
. Afinal, agora passamos não três string
idênticas, mas três entidades diferentes.
Um pouco sobre elenco
Introduzindo uma digitação bastante rigorosa, também não devemos esquecer que, ao transferir dados para o Sql, ainda precisamos obter um identificador real. E, nesse caso, é lógico atualizar levemente os tipos que envolvem uma string
:
- Adicione uma implementação de uma interface da interface do formulário
interface IValueGet<TValue>{ TValue Wrapped { get; } }
interface IValueGet<TValue>{ TValue Wrapped { get; } }
. Nesse caso, na camada de tradução no Sql, podemos obter o valor diretamente - Em vez de criar um monte de tipos mais ou menos idênticos no código, você pode criar um ancestral abstrato e herdar o restante dele. O resultado é um código do formulário:
interface IValueGet<TValue> { TValue Wrapped { get; } } abstract class BaseWrapper : IValueGet<TValue> { protected BaseWrapper(TValue initialValue) { Wrapped = initialValue; } public TValue Wrapped { get; private set; } } sealed class Name : BaseWrapper<string> { public Name(string value) :base(value) { /*no necessary validations*/ } } sealed class UserId : BaseWrapper<int> { public UserId(int id) :base(id) { /*no necessary validations*/ } }
Desempenho
Falando sobre a criação de um grande número de tipos, muitas vezes você pode encontrar dois argumentos dialéticos:
- Quanto mais tipos, aninhamento e código, mais lento o software, pois é mais difícil para o jit otimizar o programa. Portanto, esse tipo de digitação estrita levará a freios sérios no projeto.
- Quanto mais invólucros, mais o aplicativo consome memória. Portanto, adicionar wrappers aumentará seriamente os requisitos de RAM.
A rigor, ambos os argumentos são apresentados sem fatos, no entanto:
- De fato, na maioria dos aplicativos no mesmo java, as seqüências de caracteres (e matrizes de bytes) ocupam a memória principal. Ou seja, é improvável que a criação de wrappers seja perceptível para o usuário final. No entanto, devido a esse tipo de digitação, obtemos uma vantagem importante: ao analisar um despejo de memória, você pode avaliar a contribuição de cada um de seus tipos para a memória. Afinal, você vê não apenas uma lista anônima de linhas espalhadas pelo projeto. Pelo contrário, podemos entender que tipos de objetos são maiores. Além disso, devido ao fato de apenas os Wrappers conterem seqüências de caracteres e outros objetos maciços, é mais fácil entender qual a contribuição de cada tipo específico de wrapper para a memória compartilhada.
- O argumento sobre a otimização do jit é parcialmente verdadeiro, mas não está completamente completo. De fato, devido à digitação estrita, seu software começa a se livrar de inúmeras verificações na entrada das funções. Todos os seus modelos são verificados quanto à adequação em seu design. Portanto, no caso geral, você terá menos verificações (basta exigir o tipo correto). Além disso, devido ao fato de as verificações serem transferidas para o construtor e não serem manchadas pelo código, fica mais fácil determinar quais delas realmente levam tempo.
- Infelizmente, neste artigo, não posso fornecer um teste de desempenho completo, que compara um projeto com um grande número de microtipos e com o desenvolvimento clássico, usando apenas
int
, string
e outros tipos primitivos. A principal razão é que, para isso, você deve primeiro criar um projeto em negrito típico para o teste e justificar que esse projeto em particular seja típico. E com o segundo ponto, tudo é complicado, pois na vida real os projetos são realmente diferentes. No entanto, será bastante estranho fazer testes sintéticos, porque, como eu já disse, a criação de objetos de microtipos em aplicativos Enterprise, de acordo com minhas medições, sempre deixou recursos insignificantes (no nível do erro de medição).
Como você pode otimizar um código que consiste em um grande número desses microtipos.
Importante: você deve lidar com essas otimizações somente quando receber fatos garantidos de que são microtipos que retardam o aplicativo. Na minha experiência, tal situação é bastante impossível. Com uma probabilidade mais alta, o mesmo registrador o atrasará , porque cada operação está aguardando uma descarga no disco (tudo era aceitável no computador do desenvolvedor com o SSD M.2, mas um usuário com um disco rígido antigo vê resultados completamente diferentes).
No entanto, os próprios truques:
- Use tipos significativos em vez de tipos de referência. Isso pode ser útil se o Wrapper também funcionar com tipos significativos, o que significa que, em teoria, você pode passar todas as informações necessárias pela pilha. Embora se deva lembrar que a aceleração será apenas se o seu código realmente sofrer com GC frequente, precisamente por causa dos microtipos.
struct
em .Net pode causar boxe / unboxing freqüentes. E, ao mesmo tempo, essas estruturas podem exigir mais memória nas coleções Dictionary
/ Map
(já que as matrizes são alocadas com uma margem).- tipos
inline
da Kotlin / Scala têm aplicabilidade limitada. Por exemplo, você não pode armazenar vários campos neles (que às vezes pode ser útil para armazenar em cache o valor ToString
/ GetHashCode
). - Vários otimizadores são capazes de alocar memória na pilha. Em particular, o .Net faz isso para pequenos objetos temporários , e o GraalVM em Java pode alocar um objeto na pilha, mas depois copia-o para o heap se for necessário retornar (adequado para código rico em condições).
- Use o internamento de objetos (ou seja, tente pegar objetos prontos, pré-criados).
- Se o construtor tiver um argumento, você poderá criar um cache onde a chave é esse argumento e o valor é o objeto criado anteriormente. Assim, se a variedade de objetos é muito pequena, você pode simplesmente reutilizar os objetos prontos.
- Se um objeto tiver vários argumentos, você poderá simplesmente criar um novo objeto e verificar se ele está no cache. Se houver um semelhante, é melhor retornar o já criado.
- Esse esquema atrasa o trabalho dos designers, pois
Equals
/ GetHashCode
deve ser feito para todos os argumentos. No entanto, também acelera comparações futuras de objetos, se você armazenar em cache o valor do hash, porque, nesse caso, se eles forem diferentes, os objetos serão diferentes. E objetos idênticos geralmente terão um link. - No entanto, essa otimização acelerará o programa, devido ao
GetHashCode
/ Equals
mais rápido (consulte o parágrafo acima). Além disso, a vida útil dos novos objetos (que estão, no entanto, no cache) diminuirá drasticamente, para que eles entrem apenas na Geração 0.
- Ao criar novos objetos, verifique os parâmetros de entrada e não ajuste. Apesar de muitas vezes esse conselho constar do parágrafo sobre o estilo de codificação, ele permite aumentar a eficácia do programa. Por exemplo, se seu objeto exigir uma string com apenas BIG LETTERS, duas abordagens serão usadas para verificar: faça
ToUpperInvariant
partir do argumento ou verifique em um loop se todas as letras são grandes. No primeiro caso, é garantida a criação de uma nova linha; no segundo caso, um iterador máximo é criado. Como resultado, você economiza na memória (no entanto, em ambos os casos, cada caractere ainda será verificado, para que o desempenho só aumente no contexto de uma coleta de lixo mais rara).
Conclusão
Mais uma vez, repetirei o ponto importante do título: todas as coisas descritas no artigo fazem sentido em grandes projetos que foram desenvolvidos e usados há anos. Naqueles em que é significativo reduzir o custo do suporte e o custo da adição de novas funcionalidades. Em outros casos, geralmente é mais razoável fabricar um produto o mais rápido possível, sem se preocupar com testes, modelos e "bom código".
No entanto, para projetos de longo prazo, é razoável usar a digitação mais rigorosa, onde no modelo podemos descrever estritamente quais valores são possíveis em princípio.
Se o seu serviço às vezes retornar um resultado não útil, expresse-o no modelo e mostre-o ao desenvolvedor explicitamente. Não adicione mil sinalizadores com descrições na documentação.
Se seus tipos podem ser os mesmos no programa, mas são diferentes na essência dos negócios, defina-os exatamente como diferentes. Não os misture, mesmo que os tipos de seus campos sejam os mesmos.
Se você tiver dúvidas sobre produtividade, aplique o método científico e faça um teste (ou melhor, peça a uma pessoa independente para verificar tudo isso). Nesse cenário, você realmente acelerará o programa e não apenas desperdiçará o tempo da equipe. No entanto, o oposto também é verdadeiro: se houver suspeita de que seu programa ou biblioteca esteja lento, faça um teste. Não é preciso dizer que está tudo bem, apenas mostre em números.