Use seu sistema de tipos
(dzombak.com)- Ao programar, é possível usar o sistema de tipos para distinguir com clareza diferentes significados de dados
- Usar diretamente tipos genéricos como string ou inteiro faz perder o contexto e pode levar a bugs
- Mesmo com o mesmo tipo base, se você definir novos tipos de acordo com o propósito, é possível evitar erros em tempo de compilação
- Na biblioteca Go libwx, são definidos tipos que distinguem claramente unidades de medida para evitar erros causados pela mistura de
float64 - No código de exemplo, o tipo UUID é separado em UserID e AccountID, e o compilador bloqueia usos incorretos
- Mesmo em linguagens como Go, cujo sistema de tipos não é tão forte, um simples encapsulamento de tipos pode prevenir bugs
Vamos usar ativamente o sistema de tipos
Ponto de partida do problema: mistura de tipos simples
- Em programação, é comum representar muitos valores usando apenas tipos básicos como
string,inteUUID - Porém, à medida que o projeto cresce, tornam-se frequentes erros em que esses tipos simples são misturados e usados sem distinção
- Ex.: passar por engano uma string de userID como accountID, ou inverter a ordem em uma função com 3 parâmetros do tipo
int
- Ex.: passar por engano uma string de userID como accountID, ou inverter a ordem em uma função com 3 parâmetros do tipo
Solução: definir tipos que revelem a intenção
intestringsão apenas blocos de construção; se forem passados diretamente por todo o sistema, o contexto significativo se perde- Para evitar isso, é preciso definir e usar tipos próprios para cada papel
- Exemplo:
type AccountID uuid.UUID type UserID uuid.UUID func UUIDTypeMixup() { { userID := UserID(uuid.New()) DeleteUser(userID) // sem erro } { accountID := AccountID(uuid.New()) DeleteUser(accountID) // erro: não é possível usar AccountID como UserID } { accountID := uuid.New() DeleteUserUntyped(accountID) // sem erro em tempo de compilação; alta chance de problema em tempo de execução } }
- Exemplo:
- Assim, é possível barrar argumentos com tipo incorreto em tempo de compilação
Caso real de aplicação: a biblioteca libwx
- O autor aplica essa técnica em sua biblioteca Go libwx
- Para todas as unidades de medida, ele define tipos dedicados e também associa métodos de conversão de unidade aos próprios tipos
- Ex.: o método
Km.Miles()deixa a distinção de unidades explícita
- Ex.: o método
- Abaixo está um exemplo em que o compilador bloqueia ordem errada de argumentos e confusão entre unidades:
// declaração de temperatura em Fahrenheit temp := libwx.TempF(84) // declaração de umidade relativa (porcentagem) humidity := libwx.RelHumidity(67) // passado incorretamente para uma função que exige temperatura em Celsius, não Fahrenheit fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity)) // o compilador detecta imediatamente o erro de incompatibilidade de tipos // temp (tipo TempF) não pode ser usado como TempC // ordem dos argumentos passada incorretamente para a função fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp)) // o compilador impede o erro de tipos nos argumentos - Isso permite prevenir todos os erros que poderiam surgir se tudo fosse apenas
float64
Conclusão: use ativamente o sistema de tipos
- O sistema de tipos não serve apenas para verificar sintaxe, mas também é uma ferramenta de prevenção de bugs
- Vale definir tipos de ID separados para cada modelo e também encapsular argumentos de função em tipos claros em vez de usar float ou int diretamente
- Essa abordagem é muito eficaz e simples de implementar, mesmo em linguagens como Go, cujo sistema de tipos não é tão forte
- Na prática, há muitos bugs causados pela mistura de UUIDs ou strings
- O autor ressalta que é surpreendente que esse método simples ainda não seja amplamente usado em código de produção
Código relacionado
- O exemplo completo pode ser visto no GitHub:
https://github.com/cdzombak/libwx_types_lab
8 comentários
Pelo que sei, ao tentar usar isso em Kotlin, tipos primitivos acabam sendo encapsulados em wrappers, o que pode causar um problema de desempenho porque são armazenados no heap em vez da stack. Claro que, na maioria dos casos de uso, a manutenibilidade vem em primeiro lugar. Além disso, é possível minimizar o problema de desempenho usando
value class.A linguagem Ada tem, nesse aspecto, um sistema de tipos excelente. Valores de naturezas diferentes podem ser facilmente declarados como tipos distintos e, quando misturados, o compilador os detecta muito bem.
Pergunto por curiosidade. Há também alguma vantagem diferente em relação a outras linguagens de tipos populares? (
kotlin,rust,typescript, ...)As vantagens de Ada, em geral, são mais na linha de "é melhor que C". Em C, a confiança no desenvolvedor e a quantidade de coisas permitidas sem restrição são grandes. Coisas como conversões implícitas de tipo, por exemplo. Mas a maioria dos desenvolvedores parece preferir mais C, talvez por estarem acostumados com ela...
Pode ser uma característica da base de código em que trabalho, mas declaramos e usamos tipos separados para quase tudo. Usar tipos básicos fica praticamente só para índice de array.
Entendi, obrigado.
Comentários do Hacker News
Eu gosto dessa abordagem, a de "tornar estados ruins impossíveis de representar", mas um problema comum nesse padrão é que os desenvolvedores param só na primeira etapa da implementação de tipos. Tudo vira tipo, eles não se encaixam bem entre si, surgem muitos tipos levemente modificados, e o código fica difícil de rastrear e entender. Numa situação assim, eu preferiria escrever numa linguagem dinâmica com tipagem fraca (JS) ou dinâmica com tipagem forte (Elixir). Mas, se os desenvolvedores continuam levando adiante o fluxo guiado por tipos, empurrando a lógica condicional para union types compatíveis com pattern matching e aproveitando bem delegação, a experiência de desenvolvimento volta a ficar agradável. Por exemplo, dá para fazer uma função
DewPointaceitar vários tipos e ainda funcionar de forma natural.Por isso, eu queria que mais linguagens tivessem suporte nativo a tipos com intervalo limitado. Por exemplo, em vez de
x: u32, seria ótimo poder fazer o sistema de tipos impor quexsó aceite o intervalo[0,10). Isso eliminaria a necessidade de bound checks em indexação de arrays. Em casos comoOption, peephole optimization também ficaria muito mais fácil. Em Rust, graças ao LLVM, existe algum suporte desse tipo dentro da função, mas ele não se mantém quando a variável é passada entre funções.Só para constar, Ruby não tem tipagem fraca, e sim tipagem forte. Se você fizer uma operação como
1 + "1", vai receber um erro comoTypeError: String can't be coerced into Integer."Parar na primeira etapa da implementação de tipos" é a causa do fracasso. Por exemplo, começar a usar
intembrulhado emstructcomo UUID é um bom início, mas alguém pode simplesmente pegar qualquerint, empacotar no tipo e passar adiante, quebrando a propriedade de unicidade que o UUID deveria ter. No fim, o importante é ser "Correct by construction". Um tipo que precisa ser único, como UUID, não deveria poder ser criado a menos que isso seja realmente provado de alguma forma, seja lançando exceção em função/construtor, seja por outro mecanismo. Esse conceito vale não só para UUID, mas para qualquer tipo e qualquer invariante.Ultimamente tenho seguido o padrão Red-Green-Refactor, mas em vez de depender de um teste falhando, torno o sistema de tipos mais rígido para que o bug seja pego pelo type checker. Novas funcionalidades, edge cases e bugs que não podem ser induzidos via tipos eu ainda trato com testes, mas usar o sistema de tipos nesse red-green-refactor costuma ser mais rápido e pode bloquear por completo grandes categorias de bugs.
Structural types conseguem aliviar a maioria desses problemas. E, quando realmente necessário, dá para impor isso com nominal types.
Numa linha próxima de exceções e tipos, acho bom usar checked exceptions de forma apropriada para cada tipo. Não entendo por que checked exceptions do Java recebem tanta crítica. Em um projeto de que participei, eu forcei o uso delas; no começo todo mundo odiou, mas depois que se acostumaram a pensar em todos os casos excepcionais do fluxo do código, passaram a gostar. Não fui tão rígido em testes unitários, mas o projeto ficou muito robusto.
A reclamação sobre checked exceptions no Java é que tratar exceções é trabalhoso demais. Quem escreve biblioteca não consegue decidir claramente quais checked exceptions expor, e quem está do lado cliente acaba tendo que escrever tratamento de exceção inútil a cada chamada de função, então é natural passar a odiar isso. Se fosse fácil converter exceções para outro tipo ou para runtime exception, ou só declará-las no nível de módulo/app, esse problema diminuiria, mas hoje é burocrático demais. Além disso, como é fácil quebrar a assinatura, você acaba precisando usar exceções específicas de domínio, e o Java também torna desconfortável fazer esse mapeamento. Checked exceptions são boas, mas a usabilidade do tratamento de exceções em Java é ruim.
Checked exceptions passaram a ser criticadas por causa do abuso. O fato de Java suportar tanto checked quanto unchecked exceptions foi uma boa escolha. Mas o ideal é usar checked exceptions só para casos como as exceções "exogenous" mencionadas por Eric Lippert, e transformar a maioria das demais em unchecked. Por exemplo, um banco de dados pode cair a qualquer momento, mas propagar
throws SQLExceptionpor toda a call stack é burocrático demais. Dá para tratar tudo no topo com um catch-all e retornar HTTP 500. Texto relacionadoChecked exceptions, em comparação com as unchecked, têm o problema de que, se uma função profunda na call stack passar a lançar uma exceção, talvez seja preciso modificar não só o handler, mas também todas as funções intermediárias. Ou seja, o sistema fica menos flexível para mudanças. A discussão sobre o coloring de funções
asynctem uma lógica parecida. Se algo pode lançar exceção, ou você envolve comtry/catch, ou o chamador também precisa declarar que lança.C# adotou tipos claros, mas exceções unchecked, e a pilha de erros fica limpa e sem problema algum. Isso é mais limpo do que exception handlers com pattern matching fazendo tratamento bespoke em cada nível. Se houver um erro de resultado com unwrapping robusto, acho parecido.
Em Java também há um problema de baixa usabilidade com tipos checked. Por exemplo, ao usar a API de streams, é realmente ruim quando uma função
map/filterlança checked exception. Se várias chamadas de serviço têm cada uma sua própria checked exception, no fim você acaba preso entre capturarExceptionou manter uma lista absurda de exceções.No geral, concordo com a ideia de "criar tipos distintos", mas já tive muitas experiências difíceis em sistemas onde tudo era um tipo distinto. Fica especialmente complicado quando código que só move bytes de um lado para outro se mistura com código de cálculo de domínio.
Entendo bem essa sensação. Você já tem os dados de que precisa, mas antes precisa descobrir como criar o tipo ou instância, então sem uma receita parece que você está brigando com a documentação. Por exemplo, você já tem um objeto
{x, y, z}, mas precisa usar antes uma funçãocreateVector(x, y, z): Vector; e, para criar umFace, tem algo comocreateFace(vertices: Vector[]): Face, o que torna o processo desnecessariamente longo. Em algo como BouncyCastle, mesmo com um array de bytes pronto, você ainda precisa criar vários tipos e usar seus métodos antes de conseguir fazer o que realmente queria.Em Go, é relativamente fácil voltar de um alias de tipo ao tipo original, por exemplo
AccountID → int. Se a estrutura estiver bem feita, dá para seguir um estilo de clean architecture em que a lógica de domínio usa aliases de tipo e as bibliotecas que não ligam para o domínio trabalham convertendo para tipos mais altos/mais baixos, mas isso exige muito código de conversão.Phantom types são úteis nesses casos. Você adiciona um parâmetro de tipo, ou seja, um genérico, mas esse parâmetro não é usado de fato em lugar nenhum. Já usei isso em Scala ao escrever código de criptografia: os arrays eram todos de bytes, mas os phantom types impediam que eles se misturassem. Caso relacionado
Idealmente, bastaria o compilador verificar os tipos e depois reduzir toda a lógica de domínio restante a simples cópia de bytes, embora eu não tenha certeza se entendi exatamente a sua intenção.
Acho que o sistema de tipos também segue a regra 80/20. Se for levado longe demais, usar bibliotecas fica oneroso e o ganho real quase desaparece. UUID ou String são familiares, mas
AccountIDeUserIDnão são, então você precisa aprender algo novo e isso tem custo. Um sistema de tipos elaborado pode ou não valer a pena, especialmente se os testes já forem suficientes. Referência relacionadaDe todo modo, para usar o software você já precisa saber o que é
AccountouUser, então não acho que uma função comogetAccountByIdrecebendoAccountIdseja mais difícil de entender do que uma que recebe UUID.Na verdade,
Stringé só um conjunto de bytes e não tem significado nenhum por si só. JáAccountID, na maioria dos casos, deixa claro que se trata do "ID da conta". Se você realmente quiser saber a representação interna, basta olhar a definição do tipo, mas na maior parte dos contextos só saber o que éAccountIDjá basta. No fim, tipos ficam menos confusos de usar quando têm nomes claros. O link do grugbrain.dev, inclusive, parece básico demais; um grug brain provavelmente aprovaria esse nível de separação de tipos.foo(UUID, UUID)é muito pior do quefoo(AccountId, UserId). A segunda forma é autoexplicativa e ainda permite que o compilador pegue erros se você inverter a ordem dos argumentos por acidente. Isso também deixa estruturas de dados complexas mais claras sem precisar inventar novos tipos.Sobre a ideia de que "UUID ou String já são familiares": na prática, é difícil saber exatamente como UUID é armazenado ou convertido, seja GUIDv1, UUIDv4, UUIDv7 etc. Já tive, por experiência própria, que mexer manualmente em problemas de endianness na conversão entre UUID e
uniqueidentifiernuma combinação Java + MS SQL. Suspeito que seja parecido com os problemas de conversão automática de fuso horário em bancos de dados.Na prática, você já precisava entender esses tipos de qualquer jeito; caso contrário, teria acabado passando dados errados para a função.
Recentemente, nossa equipe também aplicou tipos em várias partes de um código C++ onde diferentes valores numéricos estavam sendo misturados. Começou quando fomos corrigir um bug e introduzimos tipos seguros; aí descobrimos mais três lugares com usos incorretos parecidos.
A biblioteca mp-units(documentação oficial do mp-units) me lembra um bom exemplo focado justamente nesse problema de unidades físicas. Com tipos fortes para unidades, você ganha segurança, automatiza lógica complexa de conversão de unidades e ainda pode tratar várias unidades com código genérico. Tentei levar isso para o mundo Prolog, mas os colegas ao redor não se empolgaram tanto. Exemplo para Prolog
Já trabalhei em um projeto que lidava com várias grandezas físicas — distância, velocidade, temperatura, pressão etc. — e tudo era passado simplesmente como
float, então você podia colocar uma distância no lugar de uma velocidade sem qualquer erro de compilação, e o bug só aparecia em runtime. O mesmo valia para erros de unidade, como passar km/h em vez de miles/h. Eu queria aumentar os tipos para capturar esses problemas durante o desenvolvimento, mas na época eu era júnior e era difícil convencer os outros.Eu tinha desistido de aplicar tipos por unidade física por medo de ficar complexo demais, mas agora pretendo olhar o mp-units. O fato de muitas variáveis não indicarem claramente em que unidade estão costuma causar problemas, e isso é comum também em dados externos ou funções padrão.
Em C#, você pode criar tipos assim:
Então:
Desse jeito, dá para distinguir diferentes IDs inteiros. Também dá para expandir para
IdGuidouIdString, e adicionar um novo marker type (M) exige só uma linha. Já usei variações parecidas em TypeScript e Rust.Já usei um padrão parecido, e para IDs
intoenumtem a menor friction, mas achei que ficaria confuso demais e acabei não colocando em código real. Discussão relacionadaEsse padrão é chamado de "phantom type", porque os valores de
MFooouMBarnão existem em runtime.Existem bibliotecas para isso também, como o Vogen. Vogen significa Value Object Generator, e usa geração de código-fonte para facilitar a adição de tipos de value object. No README há links para bibliotecas parecidas.
Eu já tinha visto essa abordagem antes, mas não entendia o propósito. Hoje mesmo, escrevendo uma função que recebia três argumentos
string, fiquei pensando se deveria forçar o parsing do tipo antes da chamada ou fazer isso dentro da função. Como no fim eu nem precisava do valor parseado, esse método era exatamente a resposta que eu procurava. Acho que vai ser a maior influência no meu estilo de programação este ano.Meu amigo Lukas organizou essa ideia sob o nome "Safety Through Incompatibility". Eu apliquei esse padrão por todo o meu código Go e achei extremamente útil. Ele bloqueia pela raiz o envio de IDs errados.
Texto relacionado 1
Texto relacionado 2
Em Swift existe a palavra-chave
typealias, mas, se o tipo base for o mesmo, eles continuam conversíveis livremente, então isso não atende de verdade a esse objetivo. Um wrapperstructfunciona de forma idiomática em Swift, e comExpressibleByStringLiteralaté fica razoavelmente prático. Ainda assim, seria bom ter uma nova palavra-chave como "strong typealias" (typecopy, algo assim) para indicar claramente: "isto é só uma String, mas é uma String com significado especial, então não misture com outras Strings".Na prática, a maioria das linguagens é assim. Rust/C/C++ também, por exemplo, e é agradável quando você não precisa criar um wrapper type como no exemplo de Go. Em C++, se você não marcar o construtor como
explicit, ainda precisa ter muito cuidado, porque umintpode entrar livremente no lugar de um tipoFoo.Na teoria isso parece elegante, mas aplicar na prática pode ser complicado. Em C++, por exemplo, surge a questão de compatibilidade com
std::cout, com funções de terceiros que antes recebiamString, ou com extension points já existentes.Haskell tem esse conceito com
newtype. Em linguagens OO, se o tipo não forfinal, fica fácil criar subclasses e adicionar ou especializar comportamentos. É barato e simples, sem wrapper extra ou boxing. Mas em Java isso é difícil porqueStringéfinal, então especializar a própriaStringnão é simples.Fiquei curioso sobre como exatamente você gostaria que isso funcionasse de modo diferente de um wrapper
struct.O Rust também é usado desse jeito, né? Com certeza parece algo muito bom.
Se você usar uma linguagem com um sistema de tipos bem estruturado, talvez desse para evitar esse tipo de coisa também...
Desaparecimento da Mars Climate Orbiter da NASA em setembro de 1999