Adicionando Semântica aos Parâmetros de Tipos de Base em Scala

Aqui está outro mergulho “divertido” no sistema de tipo complexo de Scala. Isso foi inspirado no post de Eric Torreborre que fornece um caso de uso muito bom para o que vou mostrar, mas eu queria compartilhar outro.

Estou dando um exemplo simples aqui, é um pouco ingênuo e haveria outras maneiras de implementá-lo, mas espero que você ainda entenda o conceito abrangente: ser capaz de anotar o significado de parâmetros de tipo básicos (e variáveis) como Int, List[String], etc.
Isso ajudará o compilador a detectar automaticamente mais erros e um revisor de código a entender melhor o que está acontecendo.

Um caso de uso

Imagine que você está tentando fazer com que os usuários pontuem produtos. Cada produto pode ser avaliado em diferentes dimensões: design, usabilidade, durabilidade.

Você pode representar uma pontuação com um objeto simples:

case class User(id: Long)
case class Product(id: Long)

case class Scoring(user: User, product: Product, design: Int, usability: Int, durability: Int)

Agora, você pode criar Scoringinstâncias em seu código Scoring(u1, p1, 10, 15, 20).

Você provavelmente acabará criando tais objetos em diferentes partes do seu código, a partir de Ints brutos vindos de diversas fontes, como uma solicitação REST ou da análise de uma linha do seu banco de dados.

O problema aqui é que o Scoringconstrutor é simples, mas não muito seguro. Se você for como eu, terá que verificar a documentação para saber em que ordem definir os parâmetros.

Por exemplo, você pode escrever algo assim, para criar uma instância com Anorm a partir de uma consulta de banco de dados:

def parseProduct(row: Row): Product = 

def parseUser(row: Row): User =

def parseScoring(row: Row): Scoring = Scoring(
parseProduct
(row),
parseUser
(row),
row
[Int]("design"),
row
[Int]("durability"),
row
[Int]("usability")
)

Existem dois erros neste código:

  1. a verificação de tipo de compilador scala realizando entregassem imediatamente bandeira dos parâmetros do produto / usuário invertidos como um erro de compilação: ,type mismatch; found : Product required: User
  2. entretanto, o segundo erro, invertendo a durabilitypontuação e a usabilitypontuação não será localizado pelo compilador e até mesmo um revisor de código teria que estar muito concentrado para localizar um erro tão trivial.

Wrappers

A solução simples seria criar classes de wrapper para cada tipo de pontuação:

case class Design(val score: Int) extends AnyVal
case class Usability(val score: Int) extends AnyVal
case class Durability(val score: Int) extends AnyVal

case class Scoring(user: User, product: Product, design: Design, usability: Usability, durability: Durability)

def parseScoring(row: Row): Scoring = Scoring(
parseUser
(row),
parseProduct
(row),
Design(row[Int]("design")),
Usability(row[Int]("usability")),
Durability(row[Int]("durability"))
)

Isso torna um pouco mais de código para escrever, mas marca explicitamente a semântica dos Ints que você está transmitindo; tornando o código mais fácil de entender e o compilador scala agora reclamará quando você criar Scoringcom as pontuações na ordem errada. Se você instanciar uma Usabilitypontuação com a "durability"coluna, o compilador não saberá a diferença, mas um revisor atento detectaria rapidamente a confusão.

Observe a AnyValextensão nas classes de caso, esse é um novo recurso do scala 2.10, chamado de classes de valor . Isso informa ao compilador que esses tipos são apenas invólucros para verificação de tipo, mas que podem ser otimizados em tempo de execução para seu valor interno, sem a criação cara do objeto de invólucro.

Esta já é uma boa solução e irá percorrer um longo caminho. Mas imagine que agora você deseja calcular a média de todas as pontuações de um produto, você pode ter uma lista de Scoringpara um produto e foldLeftnela somar todas as pontuações. No entanto, como você incluiu novos tipos, você não pode simplesmente somar, pois a operação não está definida na classe. Você acaba tendo que escrever: o que torna seu código mais complicado do que deveria ser.prod1.design + prod2.design+DesignDesign(prod1.design.score + prod2.design.score)

Você também pode implementar um +operador de proxy em todas as novas classes de caso de wrapper e replicar todas as outras operações necessárias dentro do wrapper.
Quando é apenas uma operação, para três classes de caso, tudo bem. Mas se você lida com tipos básicos mais complexos, como Stringou tipos compostos, como ou , você pode acabar tendo que fazer proxy de muitas operações em seus wrappers. Se você tiver muitos desses wrappers, pode ser muito código extra.List[Int]Future[Int]

Tipos Unboxed Tagged

A ideia que decidi seguir, inspirada no artigo de Eric , é usar “Tipos com tags não encaixotados” .
A ideia é que você possa “marcar” os tipos (e não apenas os tipos básicos) com uma palavra-chave, sem perder todas as suas operações como faria com um invólucro simples.

O princípio é simples e é a herança orientada a objetos básica: para reter todos os recursos de Intvocê pode definir uma classe:

class DesignScore extends Int

No entanto, usamos um truque de scala para tornar essa definição mais fácil e evitar realmente a criação de novos objetos: aliases de tipo .
É necessário um scaffold de código muito simples:

 //code from Eric's article
type
Tagged[U] = { type Tag = U }
type
@@[T, U] = T with Tagged[U]
  • Tagged[U]define um novo tipo, que apenas adiciona uma propriedade de tipo contendo o tipo de parâmetro U. Ou seja, é um tipo que possui uma anotação U. Então, se você escreveu , você apenas definiria um novo alias de tipo, que o estende e marca com a nova propriedade de tipo interno .type TaggedInt = Int with Tagged[Design]Inttype Tag = Design
  • @@[T, U]é apenas um atalho conveniente para estender tipos com a interface marcada. Então você pode escrever o exemplo anterior com:type TaggedInt = Int @@ Design

Você pode adicionar este snippet ao seu próprio código ou usar a implementação fornecida por scalaz , que é bastante equivalente.

Certo, isso ainda é muito abstrato; portanto, não se preocupe se ainda não estiver entendendo. Apenas veja isso como uma ferramenta para anotar tipos. Vamos ver como usá-lo agora com nosso exemplo anterior.

Primeiro, definimos “tags” que nos permitirão anotar o Inttipo básico com algumas informações contextuais:

trait Design
trait
Usability
trait
Durability

Essas características não fazem nada, exceto existir como um tipo conhecido para o compilador, você pode vê-los como constantes, ou valores de um enum, no nível do tipo. Agora podemos usá-los para definir nossos tipos de tags:

type DesignScore = Int @@ Design
type
UsabilityScore = Int @@ Usability
type
DurabilityScore = Int @@ Durability

Esses aliases de tipo representam apenas uma extensão do tipo Intcom um parâmetro de tipo incorporado Tag. Podemos usá-los como usaríamos qualquer outro tipo, como em nosso exemplo anterior:

case class Scoring(user: User, product: Product,
design
: DesignScore,
usability
: UsabilityScore,
durability
: DurabilityScore)

Porque DesignScoreé apenas uma subclasse de Int, ele herda todas as suas operações, e agora podemos usar o operador de soma: .prod1.design+prod2.design

No entanto, ainda falta algo: scala não sabe como criar um DesignScore, como quando você escreve , obtém um e scala não sabe a priori como converter isso para um subtipo de . Na verdade, isso joga a nosso favor, pois nos força a escrever explicitamente a conversão quando precisamos atribuir um inteiro a algo que precisa de um .val score = 1IntIntDesignScore

Vamos escrever uma extensão de Intque sabe fazer essa conversão:

implicit class TaggedInt(val i: Int) extends AnyVal {
def design = i.asInstanceOf[DesignScore]
def usability = i.asInstanceOf[UsabilityScore]
def durability = i.asInstanceOf[DurabilityScore]
}

Observe algumas coisas:

  1. usamos um elenco para transformar uma superclasse ( Int) em uma subclasse ( DesignScore), pode parecer sujo, mas é seguro, pois nunca encontraremos um caso em que não possamos executar o caso,
  2. estamos usando um novo recurso do scala 2.10: uma classe implícita que se estende Intcom os novos métodos,
  3. não devemos usar uma conversão implícita direta entre Inte, DesignScorepois perderíamos o fato de que agora os desenvolvedores precisam ser explícitos sobre como desejam usar o Inttipo. Nada então o impediria de usar o Int de usabilidade em um slot DesignScore.
  4. enquanto eu gosto do estilo operador postfix, você poderia apenas definir operadores unários se preferir, sem o uso de uma classe implícita em tudo: .def design(i: Int): DesignScore = i.asInstanceOf[DesignScore]

Agora podemos criar o Scoringobjeto da seguinte maneira . E em nosso exemplo anterior de análise de banco de dados:Scoring(u1, p1, 10.design, 15.usability, 20.durability)

def parseScoring(row: Row): Scoring = Scoring(
parseUser
(row),
parseProduct
(row),
row
[Int]("design").design,
row
[Int]("usability").durability,
row
[Int]("durability").usability
)

Como você pode ver, já é um pouco mais fácil detectar o erro e, se você não o fizer, o compilador reclamará de qualquer maneira.

Espero que, por meio desse exemplo simplista, você tenha entendido como os tipos de tags não encaixotados podem ajudar. Explicitamente anotar o uso de tipos básicos não semânticas ( Int, String, , …) em seu código terá dois benefícios:List[Int]

  1. em muitos casos, o compilador irá aquecê-lo quando você estiver usando um valor no slot errado.
  2. anotando assinaturas de método ( em e fora tipos), e os usos de tipos de matérias em seu código, este será mais fácil de entender e você venha a cometer menos erros e detectar rapidamente bugs.

Validação de Parâmetro

Como George Leontiev aponta, você também pode usar esse recurso para realizar a validação.

Em nosso exemplo, as pontuações são ilimitadas, você pode passar qualquer valor sem ninguém reclamar. Idealmente, você provavelmente gostaria de tê-los em um intervalo limitado, digamos entre 0 e 5.

A abordagem usual seria validar a entrada quando o usuário insere um valor. Mas então, no resto do código, não há nada que o impeça de fazer operações incorretas nos valores.

Claramente, isso não é algo com que você possa lidar em tempo de compilação, pois você não sabe quais entradas de usuário obterá ou quais serão os valores no banco de dados
. No entanto, você pode construir alguns gards para avisá-lo quando algo está errado.

Como vimos, não podemos construir os tipos marcados sem passar pela chamada explícita para os design, durabilityou usabilityfunções. Portanto, podemos apenas estender essas funções para adicionar verificações nos intervalos:

implicit class TaggedInt(val i: Int) extends AnyVal {
def design = {
require(i >= 0 && i <= 5, "the design score has to be between 0 and 5")
i
.asInstanceOf[DesignScore]
}
...
}

Agora, a validação de seus parâmetros está embutida no “tipo” que a define, se você chamar , um será lançado. Se você verificar essa exceção ao receber um valor de pontuação, poderá lidar com isso corretamente, relatar um erro ao usuário, etc.Scoring(u1, p1, 10.design, 15.usability, 20.durability)IllegalArgumentException

Se você preferir usar o validationpadrão em vez de capturar exceções, pode usar scalaz Validation monad ou scaladefault Optionou Eitherconstructos:

implicit class TaggedInt(val i: Int) extends AnyVal {
def design: Either[String, DesignScore] = {
if(i >= 0 && i <= 5){
Right(i.asInstanceOf[DesignScore])
} else {
Left("the design score has to be between 0 and 5")
}
}
...
}