It doesn't "abuse Scala's compile time behavior", the essence of Scala is this compile time behavior. The whole point of the given
s is that instead of writing out tons of boilerplate manually, it gives you the programmable term inference machinery which can generate all the boilerplate for you.
Not entirely incidentally, this term inference machinery looks a little like Prolog (because on a high enough abstraction level, even your teacup looks like a Prolog program).
Here is a basic correspondence to get you started:
- Prolog-Atoms correspond to ordinary types.
- Prolog-Variables correspond to type parameters.
- What's called "Functors" here corresponds to type constructors.
- Stating facts corresponds to providing constant
given
s.
- Making queries corresponds to
summon
ing corresponding proof-terms.
Let's start with a very simple example where we have just one atom, one prolog-"functor", one fact, and one query: this example from Wikipedia
cat(tom).
?- cat(tom).
>> Yes
can be directly translated into
trait Tom // Atom 'Tom'
trait Cat[X] // Functor 'Cat' with arity = 1
// Fact: cat(tom).
given Cat[Tom] with {}
// Query: ?- cat(tom). (is Tom a Cat?)
val query = summon[Cat[Tom]] // type-checks = Yes
Prolog rules correspond to parameterized given
-definitions, the antecedents on the right side correspond to the using
-parameters in the parameter list. For example, the classical syllogism example expressed in Prolog as
man(socrates).
mortal(X) :- man(X).
?- mortal(socrates).
>> True
can be encoded with a parameterized given
that uses Man[X]
and produces a proof of Mortal[X]
:
trait Socrates
trait Man[X]
trait Mortal[X]
given socratesIsAMan: Man[Socrates] with {}
given allMenAreMortal[X](using m: Man[X]): Mortal[X] with {}
val query = summon[Mortal[Socrates]]
You can use scala3-compiler -Xprint:typer
to see the composite proof term that the compiler generated to prove that Socrates
is Mortal
:
allMenAreMortal[Socrates](socratesIsAMan)
Knowing how to encode rules allows you to encode the more complicated example from the Wikipedia:
mother_child(trude, sally).
father_child(tom, sally).
father_child(tom, erica).
father_child(mike, tom).
sibling(X, Y) :- parent_child(Z, X), parent_child(Z, Y).
parent_child(X, Y) :- father_child(X, Y).
parent_child(X, Y) :- mother_child(X, Y).
?- sibling(sally, erica).
>> Yes
as follows:
trait Trude
trait Sally
trait Tom
trait Erica
trait Mike
trait FatherChild[F, C]
trait MotherChild[M, C]
trait ParentChild[P, C]
trait Sibling[X, Y]
given MotherChild[Trude, Sally] with {}
given FatherChild[Tom, Sally] with {}
given FatherChild[Tom, Erica] with {}
given FatherChild[Mike, Tom] with {}
given [X, Y, Z](using pczx: ParentChild[Z, X], pczy: ParentChild[Z, Y])
: Sibling[X, Y] with {}
given fatherhoodImpliesParentship[X, Y](using fc: FatherChild[X, Y])
: ParentChild[X, Y] with {}
given motherhoodImpliesParentship[X, Y](using mc: MotherChild[X, Y])
: ParentChild[X, Y] with {}
val query = summon[Sibling[Erica, Sally]] // Yes
Here, the compiler will generate a proof term that explains that Erica
and Sally
are Sibling
s because they have the same father Tom
:
given_Sibling_X_Y[Erica, Sally, Tom](
fatherhoodImpliesParentship[Tom, Erica](given_FatherChild_Tom_Erica),
fatherhoodImpliesParentship[Tom, Sally](given_FatherChild_Tom_Sally)
)
More generally, conjunctions are encoded by multiple using
-parameters, and disjunctions are encoded by multiple givens
with the same result type:
// We can write "X /\ Y" as infix operator for conjunction
case class /\[A, B](a: A, b: B)
// We can write "X \/ Y" as infix operator for disjunctions
enum \/[+A, +B]:
case Inl(a: A)
case Inr(b: B)
// Inference for conjunctions: multiple parameters in `using`
given [A, B](using a:A, b: B): (A /\ B) = /\(a, b)
// Inference for disjunctions: multiple rules
given [A](using a: A): \/[A, Nothing] = \/.Inl(a)
given [B](using b: B): \/[Nothing, B] = \/.Inr(b)
// Example:
trait X
trait Y
trait Z
trait W
given X with { override def toString = "X" }
given W with { override def toString = "W" }
@main def query(): Unit =
println(summon[(X \/ Y) /\ (Z \/ W)])
// Finds a proof and prints `/\(Inl(X), Inr(W))`.
Since Scala 3, there is even negation available through util.NotGiven
:
import scala.util.NotGiven
trait X
trait Y
trait /\[X, Y]
given X with {}
given [A, B](using a: A, b: B): /\[A, B] with {}
// Fails if we add `given Y with {}`
val query = summon[X /\ NotGiven[Y]]
Scala 3 adds a whole bunch of stuff on top of that, such as tuples (which are basically type-level lists) or computing with numeric / boolean / string singleton types, but I don't want to go too deeply into the details here.
Instead, I'd like to conclude by briefly sketching how it all fits into the landscape. The interesting difference between Prolog and Scala's type system is that the Scala compiler actually generates proof terms, and unlike in Prolog (where you get a simple "Yes"/"No"), those proof terms can carry around arbitrarily complicated computational content.
You might have noticed that in the examples above, the with {}
mostly remained empty. This is usually not the case in the real code, quite the contrary: in the real code, you usually have some non-trivial definitions in the body of every given ... with { ... }
. The reason why one is writing all those facts and inference rules is not for solving logical puzzles and obtaining "Yes" / "No" answers, but for generating huge complicated proof terms that do useful stuff.
The way it works is usually as follows:
- Suppose you want to obtain a thing
X
- You import some big-&-smart library that knows how to construct a variety of things similar to the desired
X
- You use the "predicates" ( = typeclasses) / facts / rules ( = givens) of that library to express very precisely the properties that you want the thing
X
to have
- If your description is precise enough, the library & the Scala compiler is able to summon the thing
X
purely from its type description.
So, in your average programming language, you have to write out all the terms manually. In Scala 3, you can instead specify the desired properties of the desired term through types, and the compiler will use this Prolog-like term inference system to summon a term with the right properties (given the right libraries, that is).
Yes
, while you most likely expect either "take this, this, and this, and do this" or even actually making a breakfast after you approve what it proposed, to e.g. filter out recipes that require too much time. You mentioned something like this in "The way it works..." part. How does the code look? – Surge