I would be absolutely delighted to use a system that worked like this. I think it overcomes, or could be made to overcome, basically all of the most common objections to the current system of given
s. It is slightly less compact in certain cases, but this is on purpose in order to increase regularity.
This could be tweaked, however.
The tl;dr is–use assume
to provide implicits, use given
on parameters. assume
works like val
or def
. Encode auto-filling-in with a Given
trait, so it works fine on functions too. Use standard renaming notation and/or override
to control visibility.
Anonymous classes
Creating class instances without a new
statement raises an ambiguity when trying to create an anonymous class: is a following block an argument or the declaration of an anonymous class?
To indicate the anonymous class case, we use with
to indicate that we are extending the original class:
abstract class Foo(val foo: String) { def bar: Bar }
val foo = Foo("foo") with {
def bar = Bar("bar")
}
Type inference will assume that the type is that of the extended class (Foo
in this case). If you wish for the full interface to be part of the type, use the soft keyword public
after with
:
vall foo = Foo("foo") with public {
val baz = Baz("baz")
def bar = Bar("bar")
}
Context-dependent parameters
Repetitive context-dependent parameters can be automatically supplied by using assume
statements to specify a canonical value to fill in based on the argument type and what is currently in scope.
Because this process happens implicitly, it has the potential to be difficult for a programmer to track. Therefore, parameter values are filled in contextually only when three conditions are met.
- A unique
assume
statement can deliver a value an appropriate type (the type of the argument or a supertype) - The parameter is marked as
given
, indicating that the value must be given by the compiler - The parameter is not supplied explicitly.
assume
context-dependent values
A static value is assumed using the same syntax as val
or var
:
assume theUsualFoo: Foo = Foo("foo")
Because the value is typically not referrred to by name, it can be left out by using a wildcard instead of the name:
assume _: Foo = Foo("foo")
and type inference works normally:
assume _ = Foo("foo")
A computed value is assumed using the same syntax as def
:
trait Ord[A]{ def compare(l: A, r: A): Int }
assume listOrd[A](ord: Ord[A]): List[Ord[A]] = List[Ord[A]] with {
def compare(l: List[A], r: List[A]): A = l match {
case Nil => if r.isEmpty then 0 else -1
case l0 :: lmore => r match {
case Nil => 1
case r0 :: rmore => ord.compare(l0, r0) match {
case 0 => compare(lmore, rmore)
case x => x
}
}
}
}
However, note that all parameters must themselves be assumed in the context where this assumption should apply. If you wish to denote this explicitly to remind yourself, it is okay (see given statements below).
assume listOrd[A](given ord: Ord[A]): List[Ord[A]] = List[Ord[A]] with { ... }
Type inference works as normal:
assume listOrd[A](ord: Ord[A]) = List[Ord[A]] with { ... }
The assumption can be unnamed by using a wildcard:
assume _[A](ord: Ord[A]) = List[Ord[A]] with { ... }
The parameter also can be unnamed, and can be fetched within the code using summon[Type]
:
assume _[A](_: Ord[A]) = List[Ord[A]] with {
...
case r0 :: more => summon[Ord[A]].compare(l0, r0) match { ... }
...
}
(Aside: the implementation of summon
is just inline def summon[A](given a: A): A = a
, so it is zero-overhead to use this form.)
Given parameters
A method may assume the value of one or more of its parameters. In order to mark that a parameter’s value may be assumed, mark it with the given
keyword. This parameter can then be used as normal inside the method definition:
def foo(name: String)(given bar: Bar): Foo =
bar.makeFooWithName(name)
Given parameters may be placed in the same block as regular parameters, but they must appear at the end to reduce confusion about which argument is which when specifying the normal arguments. If a parameter block consists entirely of given parameters, it may be omitted when the method is called. For a similar reason, blocks that can be omitted entirely must appear after all other parameter blocks.
def baz(name: String, given bar: Bar): Baz = ???
// def quux(given bar: Bar, name: String): Quux
// Compile-time error "given parameter appears before regular parameter"
// def bippy(given bar: Bar)(name: String): Bippy
// Compile-time error "elidable parameter block appears before mandatory parameter block"
Given parameters are automatically assumed inside the definition block of the method. Therefore they need not be named; use _
to indicate this.)
def foo(name: String)(given _: Bar): Foo =
summon[Bar].makeFooWithName(name)
Given parameters are filled in from what is available from assume
in the context of the call site. Static values will be used directly; if it is possible to synthesize a value based on the transformations provided with assume
, it will be synthesized and then used.
assume theBar = Bar("tuna")
val myFoo = foo("salmon")
val myBaz = baz("herring")
Alternatively, given parameters can be supplied explicitly by using the keyword given
in the parameter list. It is a compile-time error to specify a given parameter without the keyword. This will override any automatic source for the parameter.
val myFoo = foo("salmon")(given Bar("not your ususual Bar!"))
// val myBaz = baz("herring", Bar("use me!"))
// Compile-time error "regular parameter used in given parameter position"
Given parameters can have defaults, just like regular parameters. In this case, if no value of appropriate type can be assumed, the default will be used. They can also be specified by name at call-site, just like regular parameters. However, they still need the keyword given
before the parameter name.
def yarth(given foo: Foo, given bar: Bar = Bar("minnow")) = ???
assume theFoo = new Foo("sturgeon")
val y1 = yarth
val y2 = yarth(given Foo("bass"), given Bar("pike"))
val y3 = yarth(given Foo("bass"))
val y4 = yarth(given bar = Bar("pike"))
val y5 = {
assume theBar = Bar("perch")
yarth // Given parameters are Foo("bass") and Bar("perch")
}
Functions can also assume their parameters. Parameters that are given have type Given[A]
. When creating a closure, you can specify the parameter using given
notation, however.
val fooFn: (String, Given[Bar]) => Foo = foo _
val myFoo = fooFn("marlin") // Works if an assumed Bar is in scope
val myFoo2 = fooFn("sunfish", given Bar("perch")) // Always works
val bazFn = (s: String, given bar: Bar) => baz(s.capitalize)
// Equivalent:
// val bazFn (s: String, bar: Given[Bar]) => baz(s.capitalize)
val myBaz = bazFn("tuna")
Controlling assumptions
*Note: maybe the keyword here should be given
instead? Not sure. It seems more logical to me to have it be assume
since the assume
statements are what is being controlled.
Assumptions are imported along with wildcard imports from a package. If you wish to leave some out, they can be excluded by name or by type by using the assume
keyword and renaming them to wildcard:
import foo.{ assume myBar => _, assume Foo => _ }
Types with generic type parameters can be excluded either via wildcards or by parameterizing the type after assume
:
import foo.{ assume Context[_] => _ }
import foo.{ assume[A <: Foo] Context[A] => _ }
If you want to import only the assumes and nothing else, you can also use the assume
keyword to pick out individual types, names, or everything. Note that you cannot import assumptions by name without the assume
keyword.
import foo.{ assume myBar }
import foo.{ assume _ }
Within a block, assumptions can be controlled the same way. Note that controls must appear within braces:
def foo(name: String)(given bar: Bar): Foo = bar.fooWithName(name)
assume theBar = Bar("herring")
val myFoo =
if args.isEmpty then
assume {bar => _}
assume _(baz: Baz): Bar = baz.myBar
foo("salmon")
else
foo("salmon")
To remove all assumptions, use assume { => _ }
.
If multiple assumptions are valid at the same time, you’ll get a compile-time error:
if args.isEmpty then
assume _(baz: Baz): Bar = baz.myBar
foo("salmon")
// Compile time error: can assume more than one value for given parameter `bar` in `foo`:
// theBar: Bar on line N of Something.scala
// rule Baz => Bar on line M of This.scala
Alternatively, you can use override
to assert that the local assumption takes priority:
if args.isEmpty
override assume _(baz: Baz): Bar = baz.myBar
foo("salmon") // Works fine, uses baz.myBar