Converting code using simple type projections to dotty

I am trying to port banana-rdf to dotty. In the Scala 2 version we have an RDF trait

trait RDF {
     type Graph
     type Triple
...
}

which has a number of implementations for various libraries. Eg. for IBM’s RDF4J we have

trait Rdf4j extends RDF {
  // types related to the RDF datamodel
  type Graph = Model
  type Triple = Statement
...
}

And these are then used by library code such as PointedGraph

trait PointedGraph[Rdf <: RDF] {
  def pointer: Rdf#Node
  def graph: Rdf#Graph
}

After fixing the simple conversion problems, I now have over 300 errors of the form

  def graph: Rdf#Graph
             ^^^
  Rdf is not a legal path since it is not a concrete type

This is due to the Dropped General type projection

Has anyone got some good ideas on how to convert that?

2 Likes

I found a similar question was asked on Stack Exchange: What does Dotty offer to replace type projections. This could be a good use case to test those answers. (But other ideas also welcome)

1 Like

Yeah, that was my answer. So match types is one of approaches.

trait RDF { self =>
  type This >: this.type <: RDF /*{ type This = self.This }*/
  type Graph = GraphTyp[This]
  type Triple = TripleTyp[This]
  type Node = NodeTyp[This]
}

type GraphTyp[Rdf <: RDF] = Rdf match {
  case Rdf4j => Model
}

type TripleTyp[Rdf <: RDF] = Rdf match {
  case Rdf4j => Statement
}

type NodeTyp[Rdf <: RDF] = Rdf match {
  case Rdf4j => Value
}

trait Rdf4j extends RDF {
  type This = Rdf4j
}

trait PointedGraph[Rdf <: RDF] {
  def pointer: NodeTyp[Rdf]
  def graph: GraphTyp[Rdf]
}
1 Like

Another approach is type classes and path-dependent types

trait RDF[Rdf <: RDF[Rdf]](using val g: GraphTyp[Rdf], val t: TripleTyp[Rdf], val n: NodeTyp[Rdf]) {
  type Graph = g.Out
  type Triple = t.Out
  type Node = n.Out
}

trait GraphTyp[Rdf <: RDF[Rdf]] {
  type Out
}
object GraphTyp {
  given as GraphTyp[Rdf4j] {
    type Out = Model
  }
}

trait TripleTyp[Rdf <: RDF[Rdf]] {
  type Out
}
object TripleTyp {
  given as TripleTyp[Rdf4j] {
    type Out = Statement
  }
}

trait NodeTyp[Rdf <: RDF[Rdf]] {
  type Out
}
object NodeTyp {
  given as NodeTyp[Rdf4j] {
    type Out = Value
  }
}

trait Rdf4j extends RDF[Rdf4j] 

trait PointedGraph[Rdf <: RDF[Rdf]] {
  def pointer(using n: NodeTyp[Rdf]): n.Out
  def graph(using g: GraphTyp[Rdf]): g.Out
}

In trait RDF I had to replace type member This with type parameter (Rdf) because I needed it in implicit parameters g, t, n of the trait.

1 Like

Oh thanks, those are very helpful.
It is going to take some time to get used to this and see how best I can adapt this to the whole project.

On first look I get the impression that the match types works better for a closed world. It looks like it would require changes to the library if someone wanted to add a new type. This would not be the case with the type class and path dependent type version.

We are passing an implicit RdfOps[Rdf] around everywhere implicitly so it may be that this can be used to reduce the complexity. In a way our use of projections feel very much like path dependent types. We have Rdf#Graph where it looks like one could also have rdf.Graph now.

I’ll report back when I get a better feel for this.

1 Like

I have been trying to see how far one could get with the simple trait RDF { ... } listing all the types, and just using implicits like this:

class PointedGraph(using val rdf: RDF)(pointer: rdf.Node, graph: rdf.Graph)

which is not that far from what we had before

class PointedGraph[Rdf<:RDF]( pointer: Rdf#Node, graph: Rdf#Graph)

But this leads to the following problem with code like this:

class PointedGraphs(using val ops: RDFOps)(
  val nodes: Iterable[ops.Rdf.Node], 
  val graph: ops.Rdf.Graph
) extends Iterable[PointedGraph] {
  import ops.Rdf

The problem is that the extends clause Iterable[PointedGraph] no longer has information about the RDF type. And so we would need to pass that along in the definition and the method. I found that there was work in progress for Dependent Argument Types which would allow one to perhaps specify a type with an object argument PointedGraph(ops) as shown below:

 def /(p: Rdf.URI): PointedGraphs(ops) = { //<-- passing an object to a type
    val ns: Iterable[Rdf.Node] = this flatMap { (pointed: PointedGraph) =>
      import pointed.pointer
      ops.getObjects(graph, pointer, p)
    }
    new PointedGraphs()(ns, graph)  // note though that one needs an extra () for the implicit
  }

All of this made me wonder if one should not have a way of expressing

class PointedGraph(using val rdf: RDF)(pointer: rdf.Node, graph: rdf.Graph)

like this

class PointedGraph[val rdf: RDF](pointer: rdf.Node, graph: rdf.Graph)

But in the meantime, I may have to add the type info back. This would make declaring objects double what they were before, as I would have both to keep track of the types and also pass around the implicit object.

class PointedGraph[Rdf<:RDF](using val rdf: Rdf)(
                 pointer: rdf.Node, graph: rdf.Graph)

That would make the code look a lot worse, and make me wonder if there is another way to do this.

I can only see a way out of this by wrapping all new classes in a trait containing the type info and instances. But I think that leads to the cake pattern.

I opened a repository banana-play to be able to play with smaller parts of the translation.

I have gotten PointedGraph to work, using dependent types. It gets more complicated and a bit ugly when inheritance comes into play as shown by the need for rdf, rdf2 and rdf3 in PrefixBuilder.

So there are some tricky pieces that I thought worth reporting in issue/feature request 133 of dotty.