Search code examples
scalasyntaximplicitscala-3given

Scala3 "as" and "with" keywords used with "given"


Currently learning about Scala 3 implicits but I'm having a hard time grasping what the ​as and with keywords do in a definition like this:

given listOrdering[A](using ord: Ordering[A]) as Ordering[List[A]] with
 ​def compare(a: List[A], b: List[A]) = ...

I tried googeling around but didn't really find any good explanation. I've checked the Scala 3 reference guide, but the only thing I've found for as is that it is a "soft modifier" but that doesn't really help me understand what it does... I'm guessing that as in the code above is somehow used for clarifying that listOrdering[A] is an Ordering[List[A]] (like there's some kind of typing or type casting going on?), but it would be great to find the true meaning behind it.

As for with, I've only used it in Scala 2 to inherit multiple traits (class A extends B with C with D) but in the code above, it seems to be used in a different way...

Any explanation or pointing me in the right direction where to look documentation-wise is much appreciated!

Also, how would the code above look if written in Scala 2? Maybe that would help me figure out what's going on...


Solution

  • The as-keyword seems to be some artifact from earlier Dotty versions; It's not used in Scala 3. The currently valid syntax would be:

    given listOrdering[A](using ord: Ordering[A]): Ordering[List[A]] with
     ​ def compare(a: List[A], b: List[A]) = ???
    

    The Scala Book gives the following rationale for the usage of with keyword in given-declarations:

    Because it is common to define an anonymous instance of a trait or class to the right of the equals sign when declaring an alias given, Scala offers a shorthand syntax that replaces the equals sign and the "new ClassName" portion of the alias given with just the keyword with.

    i.e.

    given foobar[X, Y, Z]: ClassName[X, Y, Z] = new ClassName[X, Y, Z]:
      def doSomething(x: X, y: Y): Z = ???
    

    becomes

    given foobar[X, Y, Z]: ClassName[X, Y, Z] with
      def doSomething(x: X, y: Y): Z = ???
    

    The choice of the with keyword seems of no particular importance: it's simply some keyword that was already reserved, and that sounded more or less natural in this context. I guess that it's supposed to sound somewhat similar to the natural language phrases like

    "... given a monoid structure on integers with a • b = a * b and e = 1 ..."

    This usage of with is specific to given-declarations, and does not generalize to any other contexts. The language reference shows that the with-keyword appears as a terminal symbol on the right hand side of the StructuralInstance production rule, i.e. this syntactic construct cannot be broken down into smaller constituent pieces that would still have the with keyword.


    I believe that understanding the forces that shape the syntax is much more important than the actual syntax itself, so I'll instead describe how it arises from ordinary method definitions.

    Step 0: Assume that we need instances of some typeclass Foo

    Let's start with the assumption that we have recognized some common pattern, and named it Foo. Something like this:

    trait Foo[X]:
      def bar: X
      def foo(a: X, b: X): X
    

    Step 1: Create instances of Foo where we need them.

    Now, assuming that we have some method f that requires a Foo[Int]...

    def f[A](xs: List[A])(foo: Foo[A]): A = xs.foldLeft(foo.bar)(foo.foo)
    

    ... we could write down an instance of Foo every time we need it:

    f(List(List(1, 2), List(3, 4)))(new Foo[List[Int]] {
      def foo(a: List[Int], b: List[Int]) = a ++ b
      def bar: List[Int] = Nil
    })
    
    • Acting force: Need for instances of Foo
    • Solution: Defining instances of Foo exactly where we need them

    Step 2: Methods

    Writing down the methods foo and bar on every invocation of f will very quickly become very boring and repetitive, so let's at least extract it into a method:

    def listFoo[A]: Foo[List[A]] = new Foo[List[A]] {
      def foo(a: List[A], b: List[A]): List[A] = a ++ b
      def bar: List[A] = Nil
    }
    

    Now we don't have to redefine foo and bar every time we need to invoke f; Instead, we can simply invoke listFoo:

    f(List(List(1, 2), List(3, 4)))(listFoo[Int])
    
    • Acting force: We don't want to write down implementations of Foo repeatedly
    • Solution: extract the implementation into a helper method

    Step 3: using

    In situations where there is basically just one canonical Foo[A] for every A, passing arguments such as listFoo[Int] explicitly quickly becomes tiresome too, so instead, we declare listFoo to be a given, and make the foo-parameter of f implicit by adding using:

    def f[A](xs: List[A])(using foo: Foo[A]): A = xs.foldLeft(foo.bar)(foo.foo)
    
    given listFoo[A]: Foo[List[A]] = new Foo[List[A]] {
      def foo(a: List[A], b: List[A]): List[A] = a ++ b
      def bar: List[A] = Nil
    }
    

    Now we don't have to invoke listFoo every time we call f, because instances of Foo are generated automatically:

    f(List(List(1, 2), List(3, 4)))
    
    • Acting force: Repeatedly supplying obvious canonical arguments is tiresome
    • Solution: make them implicit, let the compiler find the right instances automatically

    Step 4: Deduplicate type declarations

    The given listFoo[A]: Foo[List[A]] = new Foo[List[A]] { looks kinda silly, because we have to specify the Foo[List[A]]-part twice. Instead, we can use with:

    
    given listFoo[A]: Foo[List[A]] with
      def foo(a: List[A], b: List[A]): List[A] = a ++ b
      def bar: List[A] = Nil
    

    Now, there is at least no duplication in the type.

    • Acting force: The syntax given xyz: SomeTrait = new SomeTrait { } is noisy, and contains duplicated parts
    • Solution: Use with-syntax, avoid duplication

    Step 5: irrelevant names

    Since listFoo is invoked by the compiler automatically, we don't really need the name, because we never use it anyway. The compiler can generate some synthetic name itself:

    given [A]: Foo[List[A]] with
      def foo(a: List[A], b: List[A]): List[A] = a ++ b
      def bar: List[A] = Nil
    
    • Acting force: specifying irrelevant names that aren't used by humans anyway is tiresome
    • Solution: omit the name of the givens where they aren't needed.

    All together

    In the end of the process, our example is transformed into something like

    trait Foo[X]:
      def foo(a: X, b: X): X
      def bar: X
    
    def f[A](xs: List[A])(using foo: Foo[A]): A = xs.foldLeft(foo.bar)(foo.foo)
    
    given [A]: Foo[List[A]] with
      def foo(a: List[A], b: List[A]): List[A] = a ++ b
      def bar: List[A] = Nil
    
    
    f(List(List(1, 2), List(3, 4)))
    
    • There is no repetitive definition of foo/bar methods for Lists.
    • There is no need to pass the givens explicitly, the compiler does this for us.
    • There is no duplicated type in the given definition
    • There is no need to invent irrelevant names for methods that are not intended for humans.