¿Cuáles son algunos casos de uso convincentes para los tipos de métodos dependientes?

127

Los tipos de métodos dependientes, que solían ser una característica experimental antes, ahora se han habilitado por defecto en el tronco , y aparentemente esto parece haber creado algo de emoción en la comunidad Scala.

A primera vista, no es inmediatamente obvio para qué esto podría ser útil. Heiko Seeberger publicó un ejemplo simple de tipos de métodos dependientes aquí , que como se puede ver en el comentario allí, se puede reproducir fácilmente con parámetros de tipo en los métodos. Así que ese no fue un ejemplo muy convincente. (Puede que me falte algo obvio. Corríjame si es así).

¿Cuáles son algunos ejemplos prácticos y útiles de casos de uso para tipos de métodos dependientes donde son claramente ventajosos sobre las alternativas?

¿Qué cosas interesantes podemos hacer con ellos que antes no eran posibles / fáciles?

¿Qué nos compran sobre las características existentes del sistema de tipos?

Además, ¿los tipos de métodos dependientes son análogos o se inspiran en alguna de las características que se encuentran en los sistemas de tipos de otros lenguajes de escritura avanzados como Haskell, OCaml?

missingfaktor
fuente
Te puede interesar leer haskell.org/haskellwiki/Dependent_type
Dan Burton el
Gracias por el enlace, Dan! Soy consciente de los tipos dependientes en general, pero el concepto de tipos de métodos dependientes es relativamente nuevo para mí.
missingfaktor
Me parece que los "tipos de métodos dependientes" son simplemente tipos que dependen de uno o más de los tipos de entrada de un método (incluido el tipo de objeto sobre el que se invoca el método); no hay nada loco más allá de la idea general de los tipos dependientes. Tal vez me estoy perdiendo algo?
Dan Burton
No, no lo hiciste, pero aparentemente lo hice. :-) No vi el vínculo entre los dos antes. Ahora está claro como el cristal.
missingfaktor

Respuestas:

112

Más o menos cualquier uso de tipos de miembros (es decir, anidados) puede dar lugar a la necesidad de tipos de métodos dependientes. En particular, mantengo que sin tipos de métodos dependientes, el patrón de pastel clásico está más cerca de ser un antipatrón.

¿Entonces, cuál es el problema? Los tipos anidados en Scala dependen de su instancia de cierre. En consecuencia, en ausencia de tipos de métodos dependientes, los intentos de usarlos fuera de esa instancia pueden ser frustrantemente difíciles. Esto puede convertir diseños que inicialmente parecen elegantes y atractivos en monstruosidades que son terriblemente rígidas y difíciles de refactorizar.

Ilustraré eso con un ejercicio que doy durante mi curso de entrenamiento Advanced Scala ,

trait ResourceManager {
  type Resource <: BasicResource
  trait BasicResource {
    def hash : String
    def duplicates(r : Resource) : Boolean
  }
  def create : Resource

  // Test methods: exercise is to move them outside ResourceManager
  def testHash(r : Resource) = assert(r.hash == "9e47088d")  
  def testDuplicates(r : Resource) = assert(r.duplicates(r))
}

trait FileManager extends ResourceManager {
  type Resource <: File
  trait File extends BasicResource {
    def local : Boolean
  }
  override def create : Resource
}

class NetworkFileManager extends FileManager {
  type Resource = RemoteFile
  class RemoteFile extends File {
    def local = false
    def hash = "9e47088d"
    def duplicates(r : Resource) = (local == r.local) && (hash == r.hash)
  }
  override def create : Resource = new RemoteFile
}

Es un ejemplo del patrón de pastel clásico: tenemos una familia de abstracciones que se refinan gradualmente a través de una jerarquía ( ResourceManager/ Resourcese refinan por FileManager/ Fileque a su vez se refinan por NetworkFileManager/RemoteFile ). Es un ejemplo de juguete, pero el patrón es real: se usa en todo el compilador Scala y se usó ampliamente en el complemento Scala Eclipse.

Aquí hay un ejemplo de la abstracción en uso,

val nfm = new NetworkFileManager
val rf : nfm.Resource = nfm.create
nfm.testHash(rf)
nfm.testDuplicates(rf)

Tenga en cuenta que la dependencia de la ruta significa que el compilador garantizará que los métodos testHashy solo se puedan invocar con argumentos que le correspondan, es decir. es propio y nada más.testDuplicatesNetworkFileManagerRemoteFiles

Es indudablemente una propiedad deseable, pero ¿y si quisiéramos mover este código de prueba a un archivo fuente diferente? Con los tipos de métodos dependientes es trivialmente fácil redefinir esos métodos fuera de la ResourceManagerjerarquía,

def testHash4(rm : ResourceManager)(r : rm.Resource) = 
  assert(r.hash == "9e47088d")

def testDuplicates4(rm : ResourceManager)(r : rm.Resource) = 
  assert(r.duplicates(r))

Tenga en cuenta los usos de los tipos de métodos dependientes aquí: el tipo del segundo argumento ( rm.Resource) depende del valor del primer argumento ( rm).

Es posible hacer esto sin tipos de métodos dependientes, pero es extremadamente incómodo y el mecanismo es bastante poco intuitivo: he estado enseñando este curso durante casi dos años, y en ese momento, a nadie se le ha ocurrido una solución de trabajo inesperada.

Pruébalo por ti mismo ...

// Reimplement the testHash and testDuplicates methods outside
// the ResourceManager hierarchy without using dependent method types
def testHash        // TODO ... 
def testDuplicates  // TODO ...

testHash(rf)
testDuplicates(rf)

Después de un corto tiempo luchando con él, probablemente descubrirá por qué yo (o tal vez fue David MacIver, no podemos recordar cuál de nosotros acuñó el término) lo llamo la panadería de la fatalidad.

Editar: el consenso es que Bakery of Doom fue la moneda de David MacIver ...

Para el extra: la forma de Scala de tipos dependientes en general (y los tipos de métodos dependientes como parte de ella) se inspiró en el lenguaje de programación Beta ... surgen naturalmente de la semántica de anidamiento consistente de Beta. No conozco ningún otro lenguaje de programación incluso débilmente convencional que tenga tipos dependientes en esta forma. Los idiomas como Coq, Cayenne, Epigram y Agda tienen una forma diferente de tipeo dependiente, que de alguna manera es más general, pero que difiere significativamente al ser parte de sistemas de tipos que, a diferencia de Scala, no tienen subtipos.

Miles Sabin
fuente
2
Fue David MacIver quien acuñó el término, pero en cualquier caso, es bastante descriptivo. Esta es una explicación fantástica de por qué los tipos de métodos dependientes son tan emocionantes. ¡Buen trabajo!
Daniel Spiewak
Inicialmente surgió en una conversación entre los dos en #scala hace bastante tiempo ... como dije, no puedo recordar quién de nosotros fue quien lo dijo primero.
Miles Sabin
Parece que mi memoria me estaba jugando una mala pasada ... el consenso es que fue la moneda de David MacIver.
Miles Sabin
Sí, no estaba allí (en #scala) en ese momento, pero Jorge sí y ahí es donde estaba obteniendo mi información.
Daniel Spiewak
Utilizando el refinamiento de miembros de tipo abstracto, pude implementar la función testHash4 sin problemas. def testHash4[R <: ResourceManager#BasicResource](rm: ResourceManager { type Resource = R }, r: R) = assert(r.hash == "9e47088d")Sin embargo, supongo que esto puede considerarse otra forma de tipos dependientes.
Marco van Hilst
53
trait Graph {
  type Node
  type Edge
  def end1(e: Edge): Node
  def end2(e: Edge): Node
  def nodes: Set[Node]
  def edges: Set[Edge]
}

En otro lugar, podemos garantizar estáticamente que no estamos mezclando nodos de dos gráficos diferentes, por ejemplo:

def shortestPath(g: Graph)(n1: g.Node, n2: g.Node) = ... 

Por supuesto, esto ya funcionó si se define dentro Graph, pero digamos que no podemos modificar Graphy estamos escribiendo una extensión "pimp my library" para ello.

Acerca de la segunda pregunta: los tipos habilitados por esta función son mucho más débiles que los tipos dependientes completos (consulte Programación de tipo dependiente en Agda para obtener una idea de eso). No creo haber visto una analogía antes.

Alexey Romanov
fuente
6

Esta nueva característica es necesaria cuando se utilizan miembros de tipo abstracto concreto en lugar de parámetros de tipo . Cuando se utilizan parámetros de tipo, la dependencia del tipo de polimorfismo familiar se puede expresar en la última versión y algunas versiones anteriores de Scala, como en el siguiente ejemplo simplificado.

trait C[A]
def f[M](a: C[M], b: M) = b
class C1 extends C[Int]
class C2 extends C[String]

f(new C1, 0)
res0: Int = 0
f(new C2, "")
res1: java.lang.String = 
f(new C1, "")
error: type mismatch;
 found   : C1
 required: C[Any]
       f(new C1, "")
         ^
Shelby Moore III
fuente
Esto no está relacionado. Con los miembros tipo puede usar mejoras para el mismo resultado: trait C {type A}; def f[M](a: C { type A = M}, b: M) = 0;class CI extends C{type A=Int};class CS extends C{type A=String}etc.
nafg
En cualquier caso, esto no tiene nada que ver con los tipos de métodos dependientes. Tomemos como ejemplo el ejemplo de Alexey ( stackoverflow.com/a/7860821/333643 ). El uso de su enfoque (incluida la versión de refinamiento que comenté) no logra el objetivo. Asegurará que n1.Node =: = n2.Node, pero no asegurará que ambos estén en el mismo gráfico. IIUC DMT asegura esto.
nafg
@nafg Gracias por señalar eso. Agregué la palabra concreto para dejar en claro que no me refería al caso de refinamiento para los miembros tipográficos. Hasta donde puedo ver, este sigue siendo un caso de uso válido para los tipos de métodos dependientes a pesar de su punto (que yo sabía) de que pueden tener más poder en otros casos de uso. ¿O extrañé la esencia fundamental de tu segundo comentario?
Shelby Moore III
3

Estoy desarrollando un modelo para la interoperabilidad de una forma de programación declarativa con el estado ambiental. Los detalles no son relevantes aquí (por ejemplo, detalles sobre devoluciones de llamada y similitud conceptual con el modelo de actor combinado con un serializador).

El problema relevante es que los valores de estado se almacenan en un mapa hash y se hace referencia a ellos mediante un valor de clave hash. Las funciones introducen argumentos inmutables que son valores del entorno, pueden llamar a otras funciones y escribir estados en el entorno. Pero las funciones no pueden leer valores del entorno (por lo que el código interno de la función no depende del orden de los cambios de estado y, por lo tanto, sigue siendo declarativo en ese sentido). ¿Cómo escribir esto en Scala?

La clase de entorno debe tener un método sobrecargado que ingrese una función de este tipo para llamar e ingrese las claves hash de los argumentos de la función. Por lo tanto, este método puede llamar a la función con los valores necesarios del mapa hash, sin proporcionar acceso de lectura pública a los valores (por lo tanto, según sea necesario, negando a las funciones la capacidad de leer valores del entorno).

Pero si estas claves hash son cadenas o valores hash enteros, el tipeo estático del tipo de elemento de mapa hash subsume a Any o AnyRef (el código de mapa hash no se muestra a continuación) y, por lo tanto, podría producirse una falta de coincidencia en tiempo de ejecución, es decir, sería posible para poner cualquier tipo de valor en un mapa hash para una clave hash dada.

trait Env {
...
  def callit[A](func: Env => Any => A, arg1key: String): A
  def callit[A](func: Env => Any => Any => A, arg1key: String, arg2key: String): A
}

Aunque no probé lo siguiente, en teoría puedo obtener las claves hash de los nombres de clase en tiempo de ejecución empleando classOf, por lo que una clave hash es un nombre de clase en lugar de una cadena (usando los backticks de Scala para incrustar una cadena en un nombre de clase).

trait DependentHashKey {
  type ValueType
}
trait `the hash key string` extends DependentHashKey {
  type ValueType <: SomeType
}

Así se logra la seguridad de tipo estático.

def callit[A](arg1key: DependentHashKey)(func: Env => arg1key.ValueType => A): A
Shelby Moore III
fuente
Cuando necesitamos pasar las claves de argumento en un solo valor, no lo probé, pero supongo que podemos usar una Tupla, por ejemplo, para la sobrecarga de 2 argumentos def callit[A](argkeys: Tuple[DependentHashKey,DependentHashKey])(func: Env => argkeys._0.ValueType => argkeys._1.ValueType => A): A. No usaríamos una colección de claves de argumento, porque los tipos de elementos estarían subsumidos (desconocidos en tiempo de compilación) en el tipo de colección.
Shelby Moore III
"la tipificación estática del tipo de elemento de mapa hash subsume a Any o AnyRef" - No sigo. Cuando dice tipo de elemento, ¿quiere decir tipo de clave o tipo de valor (es decir, primer o segundo argumento de tipo para HashMap)? ¿Y por qué quedaría subsumido?
Robin Green
@RobinGreen El tipo de valores en la tabla hash. Asunto, subsumido porque no puede poner más de un tipo en una colección en Scala a menos que subsuma a su supertipo común, porque Scala no tiene un tipo de unión (disyunción). Vea mis preguntas y respuestas sobre subsunción en Scala.
Shelby Moore III