Conversão implícita vs. classe de tipo

93

No Scala, podemos usar pelo menos dois métodos para adaptar os tipos existentes ou novos. Suponha que queremos expressar que algo pode ser quantificado usando um Int. Podemos definir o seguinte traço.

Conversão implícita

trait Quantifiable{ def quantify: Int }

E então podemos usar conversões implícitas para quantificar, por exemplo, Strings e Lists.

implicit def string2quant(s: String) = new Quantifiable{ 
  def quantify = s.size 
}
implicit def list2quantifiable[A](l: List[A]) = new Quantifiable{ 
  val quantify = l.size 
}

Depois de importá-los, podemos chamar o método quantifyem strings e listas. Observe que a lista quantificável armazena seu comprimento, portanto, evita o percurso caro da lista em chamadas subsequentes para quantify.

Classes de tipo

Uma alternativa é definir uma "testemunha" Quantified[A]que afirma que algum tipo Apode ser quantificado.

trait Quantified[A] { def quantify(a: A): Int }

Em seguida, fornecemos instâncias desse tipo de classe para Stringe em Listalgum lugar.

implicit val stringQuantifiable = new Quantified[String] {
  def quantify(s: String) = s.size 
}

E se escrevermos um método que precisa quantificar seus argumentos, escrevemos:

def sumQuantities[A](as: List[A])(implicit ev: Quantified[A]) = 
  as.map(ev.quantify).sum

Ou usando a sintaxe associada ao contexto:

def sumQuantities[A: Quantified](as: List[A]) = 
  as.map(implicitly[Quantified[A]].quantify).sum

Mas quando usar qual método?

Agora vem a pergunta. Como posso decidir entre esses dois conceitos?

O que percebi até agora.

classes de tipo

  • classes de tipo permitem a boa sintaxe ligada ao contexto
  • com classes de tipo, não crio um novo objeto wrapper em cada uso
  • a sintaxe ligada ao contexto não funciona mais se a classe de tipo tiver vários parâmetros de tipo; imagine que eu queira quantificar as coisas não apenas com números inteiros, mas com valores de algum tipo geral T. Eu gostaria de criar uma classe de tipoQuantified[A,T]

conversão implícita

  • como eu crio um novo objeto, posso armazenar valores em cache ou calcular uma representação melhor; mas devo evitar isso, visto que pode acontecer várias vezes e uma conversão explícita provavelmente seria invocada apenas uma vez?

O que espero de uma resposta

Apresente um (ou mais) casos de uso em que a diferença entre os dois conceitos seja importante e explique por que eu preferiria um em vez do outro. Também explicar a essência dos dois conceitos e sua relação entre si seria bom, mesmo sem exemplo.

ziggystar
fonte
Há alguma confusão nos pontos de classe de tipo onde você menciona "limite de visão", embora as classes de tipo usem limites de contexto.
Daniel C. Sobral
1
+1 excelente pergunta; Estou muito interessado em uma resposta completa para isso.
Dan Burton
@Daniel Obrigado. Eu sempre entendo errado.
ziggystar
2
Você está errado em um lugar: em seu segundo exemplo de conversão implícita, você armazena o sizede uma lista em um valor e diz que isso evita o percurso caro da lista em chamadas subsequentes para quantificar, mas em cada chamada para quantifyo list2quantifiableé acionado tudo de novo, reinstanciando Quantifiablee recalculando a quantifypropriedade. O que estou dizendo é que, na verdade, não há como armazenar em cache os resultados com conversões implícitas.
Nikita Volkov
@NikitaVolkov Sua observação está certa. E eu abordo isso na minha pergunta do penúltimo parágrafo. O cache funciona, quando o objeto convertido é usado por mais tempo após uma chamada de método de conversão (e talvez seja passado em sua forma convertida). As classes de tipo while provavelmente ficariam encadeadas ao longo do objeto não convertido ao ir mais fundo.
ziggystar

Respostas:

42

Embora eu não queira duplicar meu material do Scala In Depth , acho que vale a pena notar que as classes de tipo / características de tipo são infinitamente mais flexíveis.

def foo[T: TypeClass](t: T) = ...

tem a capacidade de pesquisar seu ambiente local por uma classe de tipo padrão. No entanto, posso substituir o comportamento padrão a qualquer momento por uma das seguintes maneiras:

  1. Criação / importação de uma instância de classe de tipo implícito no Escopo para curto-circuito na pesquisa implícita
  2. Aprovando diretamente uma classe de tipo

Aqui está um exemplo:

def myMethod(): Unit = {
   // overrides default implicit for Int
   implicit object MyIntFoo extends Foo[Int] { ... }
   foo(5)
   foo(6) // These all use my overridden type class
   foo(7)(new Foo[Int] { ... }) // This one needs a different configuration
}

Isso torna as classes de tipo infinitamente mais flexíveis. Outra coisa é que as classes / características de tipo suportam melhor a pesquisa implícita .

Em seu primeiro exemplo, se você usar uma visualização implícita, o compilador fará uma pesquisa implícita por:

Function1[Int, ?]

Que examinará Function1o objeto companheiro de e o Intobjeto companheiro.

Observe que nãoQuantifiable está em nenhum lugar da pesquisa implícita. Isso significa que você deve colocar a visão implícita em um objeto de pacote ou importá-la para o escopo. É mais trabalhoso lembrar o que está acontecendo.

Por outro lado, uma classe de tipo é explícita . Você vê o que está procurando na assinatura do método. Você também tem uma pesquisa implícita de

Quantifiable[Int]

que examinará Quantifiableo objeto companheiro de e Int o objeto companheiro de. O que significa que você pode fornecer padrões e novos tipos (como uma MyStringclasse) podem fornecer um padrão em seu objeto companheiro e ele será pesquisado implicitamente.

Em geral, eu uso classes de tipo. Eles são infinitamente mais flexíveis para o exemplo inicial. O único lugar em que uso conversões implícitas é quando uso uma camada de API entre um wrapper Scala e uma biblioteca Java, e mesmo isso pode ser 'perigoso' se você não tomar cuidado.

jsuereth
fonte
20

Um critério que pode entrar em jogo é como você deseja que o novo recurso "pareça"; usando conversões implícitas, você pode fazer parecer que é apenas outro método:

"my string".newFeature

... ao usar classes de tipo, sempre parecerá que você está chamando uma função externa:

newFeature("my string")

Uma coisa que você pode alcançar com classes de tipo e não com conversões implícitas é adicionar propriedades a um tipo , em vez de uma instância de um tipo. Você pode acessar essas propriedades mesmo quando não tiver uma instância do tipo disponível. Um exemplo canônico seria:

trait Default[T] { def value : T }

implicit object DefaultInt extends Default[Int] {
  def value = 42
}

implicit def listsHaveDefault[T : Default] = new Default[List[T]] {
  def value = implicitly[Default[T]].value :: Nil
}

def default[T : Default] = implicitly[Default[T]].value

scala> default[List[List[Int]]]
resN: List[List[Int]] = List(List(42))

Este exemplo também mostra como os conceitos estão intimamente relacionados: as classes de tipo não seriam tão úteis se não houvesse um mecanismo para produzir um número infinito de suas instâncias; sem o implicitmétodo (não é uma conversão, admito), eu poderia apenas ter finitamente muitos tipos com a Defaultpropriedade.

Philippe
fonte
@Phillippe - Estou muito interessado na técnica que você escreveu ... mas parece não funcionar no Scala 2.11.6. Publiquei uma pergunta pedindo uma atualização em sua resposta. obrigado antecipadamente se você puder ajudar: Consulte: stackoverflow.com/questions/31910923/…
Chris Bedford
@ChrisBedford adicionei a definição de defaultpara futuros leitores.
Philippe,
13

Você pode pensar na diferença entre as duas técnicas por analogia ao aplicativo de função, apenas com um wrapper nomeado. Por exemplo:

trait Foo1[A] { def foo(a: A): Int }  // analogous to A => Int
trait Foo0    { def foo: Int }        // analogous to Int

Uma instância do primeiro encapsula uma função do tipo A => Int, enquanto uma instância do último já foi aplicada a um A. Você poderia continuar o padrão ...

trait Foo2[A, B] { def foo(a: A, b: B): Int } // sort of like A => B => Int

assim, você pode pensar em Foo1[B]uma aplicação parcial de Foo2[A, B]a alguma Ainstância. Um grande exemplo disso foi escrito por Miles Sabin como "Dependências funcionais no Scala" .

Então, o que quero dizer é que, em princípio:

  • "cafetão" de uma classe (por meio de conversão implícita) é o caso de "ordem zero" ...
  • declarar uma typeclass é o caso de "primeira ordem" ...
  • typeclasses multiparâmetros com fundeps (ou algo como fundeps) é o caso geral.
conflito de fusão
fonte